This commit is contained in:
root
2026-01-01 02:13:34 +01:00
commit b05793228a
29 changed files with 4608 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__
venv
deny_auto.conf
logs/*

BIN
GeoIP/GeoLite2-City.mmdb Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 MiB

0
README.md Normal file
View File

1823
app.py Normal file

File diff suppressed because it is too large Load Diff

14
autoban.service Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=autoban with Waitress
After=network.target
[Service]
#User=your_user
#Group=your_group
WorkingDirectory=/opt/autoban
ExecStart=/opt/autoban/venv/bin/python /opt/autoban/run.py
Restart=always
Environment=PATH=/opt/autoban/venv/bin
[Install]
WantedBy=multi-user.target

432
config.py Normal file
View File

@@ -0,0 +1,432 @@
import os
SKIP_BACKUP_PREFIXES = (
"redirectlog",
"stats:ip",
"stats:user_agent",
"stats:referrer",
"stats:host:",
"sequence:", # per-IP sekwencje żądań (ltrim, ale bez TTL)
"requests:", # per-IP ostatnie requesty (ltrim, ale bez TTL)
)
CONFIG = {
"basic_auth": {
"username": "admin",
"password": "admin"
},
"api_keys": {
"default": "d844X5w4GJ7X29tvp3bQ48"
},
"api_trusted_networks": [
"127.0.0.1/32",
"::1/128",
"10.87.0.0/16",
"172.16.0.0/24"
],
"trusted_proxies": ["127.0.0.1", "::1"],
"sqlite_db": os.path.join(os.path.dirname(__file__), "redis_backup.sqlite3"),
"webserver_bin": "/usr/sbin/angie",
"log_files": [
"/var/log/angie/unitraklub.pl_access.log",
"/var/log/angie/unitra.eu.org_access.log",
"/var/log/angie/unitrafan.pl_access.log",
],
"redis_host": "localhost",
"redis_port": 6379,
"redis_db": 8,
"thresholds": {
"requests": 25000,
"errors": 10,
"success_requests": 20000,
"error_codes": [404, 500, 410],
"requests_time_window": 3600,
"ban_duration": 15552000,
"request_size": 256000000,
},
"deny_file": "/etc/angie/conf.d/deny_auto.conf",
#'deny_file': 'deny_auto.conf',
"geoip_db": "GeoIP/GeoLite2-City.mmdb",
"api_port": 5002,
"prometheus_port": 9502,
"pushover": {
"token": "afh4yqrybtf7jnznyapq2bs1wcdmiq",
"user_key": "u629MYggcYdRs6UM3TdYJviHWmcdKe",
"enabled": True,
},
"whitelist_endpoints": [
"/customerror/404",
"/block_refresh/quicktabs/3",
"/block_refresh/block/10",
"/block_refresh/views/apk_user_tracker_page-block_1",
"/klubowyczat/includes/json/receive/receive_core.php",
"/klubowyczat/includes/json/receive/receive_buddylist.php",
],
"notification_batching": {
"enabled": True,
"batch_window": 43200, # X minut w sekundach
"threshold": 25, # minimum banów do wysłania powiadomienia
"max_window": 21600, # maksymalny czas oczekiwania
"summary_limit": 10, # ile najczęstszych IP pokazać w podsumowaniu
},
"attack_patterns": {
"path_traversal": [
r"\.\./",
r"\.\.\\",
r"%2e%2e%2f", # ../
r"%2e%2e%5c", # ..\
r"%252e%252e%252f", # podwójnie kodowane ../
r"%c0%ae%c0%ae%c0%af", # UTF-8 overlong ../
r"%uff0e%uff0e%u2215", # Unicode fullwidth
r"/etc/passwd",
r"/etc/shadow",
r"/proc/self/environ",
r"c:\\windows\\system32",
r"c:\\boot\\.ini",
r"web\.config",
r"\.\./\.\./", # ../../
r"\\..\\", # UNC style
],
"command_injection": [
r";\s*cat\s+/etc/passwd",
r";\s*ls\s+",
r";\s*id\s*;",
r";\s*whoami\s*;",
r"&&\s*cat\s+",
r"\|\s*cat\s+",
r"`cat\s+/etc/passwd`",
r"\$\(cat\s+/etc/passwd\)",
r";\s*wget\s+",
r";\s*curl\s+",
r";\s*nc\s+",
r";\s*bash\s*;",
],
"nosql_injection": [
r"\$ne:",
r"\$gt:",
r"\$lt:",
r"\$where:",
r"\$regex:",
r"\$exists:",
r'{"username":\s*{"?\$ne',
r'{"password":\s*{"?\$ne',
r";\s*db\.dropDatabase\(\)",
r"MapReduce",
r"\$eval:",
],
"ldap_injection": [
r"\*\)\(cn=\*",
r"\)\(\|",
r"\(\|\(",
r"\)\(uid=\*",
r"\(\&\(",
r"admin\)\(\|",
r"\*\)\(userPassword=\*",
r"\(\!\(",
r"\)%00",
],
"xxe": [
r"<!ENTITY.*?SYSTEM",
r"<!ENTITY.*?file://",
r"<!ENTITY.*?http://",
r"<!ENTITY.*?https://",
r"<!ENTITY.*?ftp://",
r"<!ENTITY.*?expect://",
r"&xxe;",
r"<!DOCTYPE.*?\[",
r'SYSTEM\s+["\']file://',
],
"ssti": [
r"\{\{.*?\}\}",
r"\{\%.*?\%\}",
r"\$\{.*?\}",
r"\{\{7\*7\}\}",
r"\{\{config\}\}",
r"\{\{request\}\}",
r"\{\{self\}\}",
r"<%.*?%>",
r"\{\{.*?\.\_\_class\_\_.*?\}\}",
],
"csrf": [
r'<form.*?action=["\']https?://(?!.*?example\.com)',
r'<img.*?src=["\']https?://(?!.*?example\.com).*?\.php',
r'<iframe.*?src=["\']https?://(?!.*?example\.com)',
r"XMLHttpRequest.*?open.*?POST",
r'fetch.*?method:\s*["\']POST["\']',
],
"deserialization": [
r"O:\d+:",
r"a:\d+:\{",
r"java\.io\.Serializable",
r"rO0AB",
r"H4sIA",
r"__reduce__",
r"__setstate__",
r"pickle\.loads",
r"cPickle\.loads",
r"yaml\.load",
r"unserialize\(",
],
"host_header_injection": [
r"Host:\s*[^.\r\n]+\.[^.\r\n]+\.[^.\r\n]+",
r"X-Forwarded-Host:\s*[^.\r\n]+\.[^.\r\n]+",
r"X-Host:\s*[^.\r\n]+\.[^.\r\n]+",
r"Host:\s*localhost:\d{4,5}",
r"Host:\s*127\.0\.0\.1:\d+",
],
"open_redirect": [
r"(redirect|url|return|next|target)=https?://",
r"(redirect|url|return|next|target)=%2F%2F",
r"(redirect|url|return|next|target)=//[^/]",
r"Location:\s*https?://(?!.*?example\.com)",
r"window\.location.*?=.*?http://",
r"document\.location.*?=.*?http://",
],
"information_disclosure": [
r'(password|passwd|pwd|secret|key|token)[\s]*[:=][\s]*["\'][^"\']{8,}',
r"mysql_connect\(",
r"pg_connect\(",
r"stack trace",
r"Fatal error:",
r"Warning:.*?on line",
r"Error:.*?at line",
r"\.git/",
r"\.env",
r"config\.php",
r"backup\.",
r"\.bak",
r"\.old",
],
"business_logic": [
r"(price|amount|quantity|discount)=0",
r"(price|amount|quantity|discount)=-\d+",
r"(role|privilege|level|type)=admin",
r"(role|privilege|level|type)=administrator",
r"bypass=true",
r"test=true",
r"debug=true",
r"admin=true",
],
"session_attacks": [
r"PHPSESSID=.*?\w{26,}",
r"JSESSIONID=.*?\w{32,}",
r"session_id=.*?\w{32,}",
r"sid=.*?\w{16,}",
r"Cookie:.*?sessionid=fixed",
r"Set-Cookie:.*?secure=false",
r"Set-Cookie:.*?httponly=false",
],
"sqli_extended": [
# MongoDB NoSQL
r"\$where:\s*function\(\)",
r'ObjectId\(["\'][^"\']*["\']\)',
# PostgreSQL specific
r"pg_sleep\(\d+\)",
r"COPY.*?FROM.*?PROGRAM",
# Oracle specific
r"UTL_HTTP\.REQUEST",
r"SYS\.DBMS_EXPORT_EXTENSION",
],
"xss_extended": [
# DOM-based XSS
r"document\.write\(",
r"innerHTML\s*=",
r"outerHTML\s*=",
# Event handlers
r'on\w+\s*=\s*["\'][^"\']*script',
# Data URIs
r"data:text/html,",
r"data:image/svg\+xml",
# JavaScript protocols
r"vbscript:",
r"livescript:",
],
"clickjacking": [
r"<iframe.*?opacity\s*:\s*0",
r"<iframe.*?visibility\s*:\s*hidden",
r"<iframe.*?display\s*:\s*none",
r"position\s*:\s*absolute.*?top\s*:\s*-\d+",
r"z-index\s*:\s*-?\d+",
],
"sqli": [
r"\b(UNION\s+SELECT|SELECT\s+.*?\s+FROM|INSERT\s+INTO|UPDATE\s+.*?\s+SET|DELETE\s+FROM)\b",
r"\bOR\s+1=1\b",
r"\bEXEC\(.*\)",
r"\bWAITFOR\s+DELAY\b",
r"\b(SLEEP\(\d+\)|BENCHMARK\(\d+\))",
r"\b(CAST|CONVERT)\(.*AS.*\)",
r"\bINFORMATION_SCHEMA\.TABLES\b",
r"\b0x[0-9a-fA-F]+\b",
r"\b(;--|#|/\*)\s*$",
],
"xss": [r"<script.*?>", r"javascript:", r"onerror=", r"alert\(.*\)"],
"drupal": [
r"/user/register\?element_parents=account/mail/%23value&.*_wrapper_format=drupal_ajax",
r"/file/ajax/.*/upload",
r"POST\s+/node/\d+/?_format=hal_json",
r"POST\s+/user/.*?_format=hal_json",
r"_drupal_ajax=1&form_id=.*_form",
r"/_entity_embed/.*?/embed",
r"linkit/match\?search=.*<script",
r"/_jsonapi\?.*filter\[.*\]\[condition\]\[path\]=.*",
r"/admin/config/development/configuration/single/export",
r"rest_export=1",
r"/taxonomy/term/\d+/edit",
],
"rce": [
r"\b(passthru|shell_exec|phpinfo|proc_open|popen)\b",
r"\.\./\.\./\.\./\.\./",
r"/\$(?:\\$\$)+/",
],
"lfi": [
r"\.\./\.\./\.\./\.\./",
r"(etc/passwd|proc/self/environ)",
r"php://filter/convert.base64-encode/resource=",
],
"ssrf": [
r"(127\.0\.0\.1|localhost|169\.254\.169\.254)",
r"(\?|&)url=(http|https|ftp|file)",
r"/(metadata|instance-data)/",
r"accesskeyid|secretkey",
],
"xxe": [r"<!ENTITY.*SYSTEM.*>", r"%xxe;", r"DOCTYPE.*ENTITY", r"jar:file:/"],
"ci_cd": [
r"/(\.git|\.svn|\.hg)/",
r"/(Jenkinsfile|\.travis\.yml|circleci/config\.yml)",
r"/(docker-compose\.yml|Dockerfile)",
r"/(package\.json|requirements\.txt)",
r"/_apis/build",
r"/api/v4/ci",
r"/(github|gitlab)-webhook",
r"/(bitbucket-pipelines\.yml)",
],
},
"bruteforce": {
"login_urls": [
# klasyczne
"/user/login",
"/login",
"/signin",
"/users/login",
"/account/login",
"/auth/login",
"/admin/login",
"/admin.php",
"/admin/index.php",
"/administrator/",
"/administrator/index.php",
"/cpanel",
"/phpmyadmin",
# WordPress / CMS
"/wp-login.php",
"/wp-login.php?action=lostpassword",
"/xmlrpc.php",
"/wp-admin/",
"/wp-admin/admin-ajax.php",
"/wp-content/",
"/typo3/index.php",
"/joomla/administrator/",
# API loginy
"/oauth/token",
"/graphql",
"/api/login",
"/api/auth",
"/api/v1/login",
"/rest/user/login",
"/rest/auth/login",
"/admin/auth/login",
# podejrzane / backdoory
"/makeasmtp.php",
"/updates.php",
"/yanz.php",
"/pwnd.php",
"/wp-l0gin.php",
"/wlwmanifest.xml",
"/classwithtostring.php",
"/0x.php",
"/shell.php",
"/cmd.php",
"/login.php",
"/logon.php",
"/portal/redlion",
],
"attempts_threshold": 15,
"time_window": 300,
"rate_limits": {
"api": {"path_regex": r"^/klubowyczat/.*", "limit": 250, "window": 60},
"assets": {
"path_regex": r"\.(js|css|png|jpg|jpeg)$",
"limit": 750,
"window": 60,
},
},
},
"sequence": {
"window_size": 15,
"suspicious_patterns": [
{
"pattern": ["/user/password", "/user/login", "/user/register"],
"score": 10,
},
{"pattern": ["/node/add", "/admin/content", "/admin/users"], "score": 8},
{"pattern": ["/wp-admin", "/wp-login.php", "/xmlrpc.php"], "score": 12},
{"pattern": ["/.env", "/config.php", "/database.ini"], "score": 15},
{"pattern": ["/v1/api", "/v1/admin", "/v1/user"], "score": 7},
{"pattern": ["/phpinfo.php", "/info.php", "/test.php"], "score": 10},
],
"threshold": 15,
"time_based_sequences": [
{
"pattern": ["/api/auth/token", "/api/user/me"],
"time_window": 2,
"threshold": 10,
},
{
"pattern": ["/password/reset", "/login"],
"time_window": 30,
"threshold": 5,
},
],
},
"api_abuse": {
"graphql": [
r"(__schema|introspection)",
r"mutation\s+\{",
r"query\s+\{\s+__typename",
],
"rest": [r"/(v1|api)/.*(\$|\|)", r"%24%7B.*%7D", r"/api/.*%0A"],
},
"scanner_signatures": [
r"(nmap|acunetix|nessus|nikto)",
r"(sqlmap|w3af|zap|burp)",
r"libwww-perl|curl|python-requests",
r"(zgrab|masscan|metasploit)",
],
"whitelist": {
"user_agents": [
r"Googlebot",
r"Bingbot",
r"DuckDuckBot",
r"YandexBot",
r"Twitterbot",
r"Applebot",
r"LinkedInBot",
r"AdsBot-Google",
r"SeznamBot",
r"facebot",
],
"ip_ranges": [
"66.249.64.0/19",
"157.55.39.0/24",
"207.46.0.0/16",
"146.75.0.0/16",
"141.144.232.95/32",
],
"log_lines_limit": 50,
},
"stats_retention": {"week": 4, "month": 1, "year": 0},
}

17
nginx-autoban.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=NGINX AutoBan Service
After=network.target redis-server.service nginx.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/autoban
ExecStart=/opt/autoban/venv/bin/python3 /opt/autoban/app.py
Restart=always
RestartSec=5
Environment=PATH=/opt/autoban/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=PYTHONPATH=/opt/autoban
[Install]
WantedBy=multi-user.target

12
nginx-vhost.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name autoban.local;
location / {
proxy_pass http://127.0.0.1:5002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
flask
Werkzeug
redis
geoip2
requests
prometheus-client
scikit-learn
pandas
psutil
ipaddress
ua-parser
pyyaml
markupsafe
Werkzeug
waitress

8
run.py Normal file
View File

@@ -0,0 +1,8 @@
from app import app, start_background_threads
from waitress import serve
# Uruchomienie wątków tła natychmiast po starcie aplikacji
start_background_threads()
# Uruchomienie aplikacji przy użyciu Waitress na porcie 5002
serve(app, listen='*:5002', ident="")

71
static/css/base.css Normal file
View File

@@ -0,0 +1,71 @@
/* === Ogólne === */
body {
background-color: #121212;
color: #ffffff;
}
/* === Karty === */
.card {
background-color: #1e1e1e;
border: 1px solid #333333;
}
.card-header {
background-color: #212529;
color: #ffffff;
}
/* === Tabele === */
.table {
background-color: #1e1e1e;
color: #ffffff;
}
.table thead th {
background-color: #343a40;
}
/* === Listy === */
.list-group-item {
background-color: #1e1e1e;
color: #ffffff;
border: 1px solid #333333;
}
/* === Formularze === */
.form-control,
.form-select {
background-color: #212529;
color: #ffffff;
border: 1px solid #343a40;
}
/* === Linki === */
a.text-decoration-none:hover {
text-decoration: underline;
}
/* === Lista banów === */
tr.ban-row {
cursor: pointer;
}
td.ban-ip {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
#selection-counter {
opacity: 0.8;
}
th:first-child,
td:first-child {
width: 42px;
}
.navbar .form-control::placeholder {
color: #adb5bd;
opacity: 1;
}

146
static/js/bans.js Normal file
View File

@@ -0,0 +1,146 @@
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// --- SEARCH / FILTER (client-side) ---
const searchInput = $('#search-input');
const rows = () => $$('#bans-tbody tr.ban-row');
const noDataRow = () => $('#bans-tbody tr[data-empty]');
function applySearch() {
const q = (searchInput?.value || '').trim().toLowerCase();
let visible = 0;
rows().forEach(r => {
const text = r.textContent.toLowerCase();
const show = !q || text.includes(q);
r.classList.toggle('d-none', !show);
if (show) visible++;
});
if (!visible && !noDataRow()) {
const tr = document.createElement('tr');
tr.setAttribute('data-empty', '');
tr.innerHTML = '<td colspan="6" class="text-center py-5 text-secondary">Brak wyników dla podanego filtra</td>';
$('#bans-tbody').appendChild(tr);
} else if (visible && noDataRow()) {
noDataRow().remove();
}
}
let t;
searchInput?.addEventListener('input', () => { clearTimeout(t); t = setTimeout(applySearch, 120); });
// --- BULK SELECT ---
const selectAll = $('#select-all');
const counter = $('#selection-counter');
const delBtn = $('#delete-selected');
const bulkForm = $('#bulk-form');
function currentChecks() { return $$('.row-check'); }
function selectedCount() { return currentChecks().filter(c => c.checked).length; }
function updateState() {
const total = currentChecks().length;
const sel = selectedCount();
if (counter) counter.textContent = sel + ' zaznaczonych';
if (delBtn) delBtn.disabled = sel === 0;
if (selectAll) {
selectAll.checked = sel > 0 && sel === total;
selectAll.indeterminate = sel > 0 && sel < total;
}
}
selectAll?.addEventListener('change', () => {
currentChecks().forEach(c => (c.checked = selectAll.checked));
updateState();
});
document.addEventListener('change', (e) => {
if (e.target.classList?.contains('row-check')) updateState();
});
// Kliknięcie w wiersz przełącza zaznaczenie (poza elementami interaktywnymi)
document.addEventListener('click', (e) => {
const row = e.target.closest('tr.ban-row');
if (!row) return;
if (e.target.closest('input,button,a,label,select,textarea')) return;
const cb = row.querySelector('.row-check');
if (cb) { cb.checked = !cb.checked; updateState(); }
});
updateState();
// Potwierdzenia akcji
bulkForm?.addEventListener('submit', (e) => {
const submitter = e.submitter;
if (!submitter) return;
const sel = selectedCount();
const isDeleteAll = submitter.name === 'delete_all';
const msg = isDeleteAll ? 'Usunąć WSZYSTKIE bany?' : `Usunąć ${sel} wybrane bany?`;
if (!confirm(msg)) e.preventDefault();
});
// --- ADD BAN: walidacja klientowa ---
const addForm = $('#add-ban-form');
addForm?.addEventListener('submit', (e) => {
if (!addForm.checkValidity()) {
e.preventDefault();
addForm.classList.add('was-validated');
}
});
// --- DETAILS MODAL ---
function formatBanDetails(data) {
let html = '<div class="table-responsive"><table class="table table-bordered table-dark">';
html += '<thead><tr><th>Klucz</th><th>Wartość</th></tr></thead><tbody>';
for (let key in data) {
let value = data[key];
if (key === 'attack_details' || key === 'geo') {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
let nested = '<table class="table table-sm table-bordered table-dark mb-0">';
for (let subKey in parsed) nested += `<tr><td>${subKey}</td><td>${parsed[subKey]}</td></tr>`;
nested += '</table>';
value = nested;
} catch (_) { }
}
html += `<tr><td>${key}</td><td>${value}</td></tr>`;
}
html += '</tbody></table></div>';
return html;
}
// Otwórz modal po kliknięciu IP
document.addEventListener('click', (e) => {
const btn = e.target.closest('.ban-ip');
if (!btn) return;
const ip = btn.dataset.ip;
const modalEl = document.getElementById('banModal');
const bodyEl = document.getElementById('banModalBody');
const titleEl = document.getElementById('banModalLabel');
if (titleEl) titleEl.textContent = 'Szczegóły bana dla ' + ip;
if (bodyEl) {
bodyEl.innerHTML = '<div class="placeholder-glow"><span class="placeholder col-12"></span><span class="placeholder col-10"></span></div>';
}
fetch('/api/banned/' + encodeURIComponent(ip) + '?full_info=1')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => { bodyEl.innerHTML = formatBanDetails(data); })
.catch(() => { bodyEl.innerHTML = '<div class="text-danger">Nie udało się pobrać szczegółów.</div>'; })
.finally(() => {
const m = new bootstrap.Modal(modalEl);
m.show();
});
});
document.getElementById('delete-all-btn')?.addEventListener('click', async () => {
if (!confirm('Usunąć WSZYSTKIE bany?')) return;
try {
const res = await fetch('/api/banned/all', { method: 'DELETE' });
if (!res.ok) throw new Error('HTTP ' + res.status);
window.showToast?.({ text: 'Wszystkie bany usunięte', variant: 'success' });
location.reload();
} catch (e) {
window.showToast?.({ text: 'Nie udało się usunąć wszystkich banów', variant: 'danger' });
}
});
const url = new URL(location.href);
if (url.searchParams.get('created') === '1') {
window.showToast?.({ text: 'Ban został dodany.', variant: 'success' });
}
})();

279
static/js/charts.js Normal file
View File

@@ -0,0 +1,279 @@
/* eslint-disable no-undef */
// charts.js — UX v2 (fixed quick-top)
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// Kolory dla dark mode
const palette = {
grid: 'rgba(255,255,255,0.1)',
tick: 'rgba(255,255,255,0.7)',
bar: 'rgba(54, 162, 235, 0.8)',
bar2: 'rgba(255, 159, 64, 0.85)',
line: 'rgba(255, 99, 132, 1)',
lineFill: 'rgba(255, 99, 132, 0.2)',
pie: [
'rgba(255, 99, 132, 0.85)',
'rgba(54, 162, 235, 0.85)',
'rgba(255, 206, 86, 0.85)',
'rgba(75, 192, 192, 0.85)',
'rgba(153, 102, 255, 0.85)',
'rgba(255, 159, 64, 0.85)',
'rgba(199, 199, 199, 0.85)'
]
};
function emptyState(canvasId, emptyId, labels, dataArr) {
const hasData = Array.isArray(labels) && labels.length > 0 && Array.isArray(dataArr) && dataArr.some(v => v > 0);
const empty = document.getElementById(emptyId);
const canvas = document.getElementById(canvasId);
if (!hasData) {
canvas?.classList.add('d-none');
empty?.classList.remove('d-none');
return true;
}
canvas?.classList.remove('d-none');
empty?.classList.add('d-none');
return false;
}
function csvFromSeries(labels, data, headerX = 'Label', headerY = 'Value') {
const rows = [[headerX, headerY], ...labels.map((l, i) => [l, data[i] ?? 0])];
return rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
}
function download(filename, content, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function bindDownloads(id, labels, data) {
$$('.chart-download').forEach(btn => {
if (btn.dataset.target !== id) return;
btn.addEventListener('click', () => {
const type = btn.dataset.type;
if (type === 'csv') {
download(`${id}.csv`, csvFromSeries(labels, data), 'text/csv;charset=utf-8');
} else if (type === 'png') {
const c = document.getElementById(id);
if (!c) return;
const url = c.toDataURL('image/png');
const a = document.createElement('a');
a.href = url; a.download = `${id}.png`; a.click();
}
});
});
}
function bindFullscreen(id) {
$$('.chart-fullscreen').forEach(btn => {
if (btn.dataset.target !== id) return;
btn.addEventListener('click', () => {
const src = document.getElementById(id);
const dst = document.getElementById('chartModalCanvas');
const chart = Chart.getChart(src);
if (!chart) return;
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
modal.show();
setTimeout(() => {
const old = Chart.getChart(dst);
old && old.destroy();
new Chart(dst.getContext('2d'), {
type: chart.config.type,
data: JSON.parse(JSON.stringify(chart.config.data)),
options: Object.assign({}, chart.config.options, {
maintainAspectRatio: false
})
});
}, 150);
});
});
}
// Bind przycisków szybkiego wyboru: nie wysyłamy duplikatu name,
// tylko ustawiamy <select name="top_n"> i submitujemy formularz.
function bindQuickTop() {
const form = document.getElementById('options-form');
const select = document.getElementById('top_n');
if (!form || !select) return;
$$('.quick-top').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.value;
if (val && select) select.value = String(val);
// odśwież aktywny stan w UI (opcjonalnie, dla natychmiastowego feedbacku)
$$('.quick-top').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
form.requestSubmit();
});
});
// Zmiana w <select> nadal auto-submituje:
select.addEventListener('change', () => form.requestSubmit());
}
// Auto-inicjalizacja niezależnie od kolejności ładowania
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', bindQuickTop);
} else {
bindQuickTop();
}
function bindQuickPeriod() {
const form = document.getElementById('options-form');
const select = document.getElementById('period');
if (!form || !select) return;
// Szybkie przyciski okresu (Tydzień/Miesiąc/Rok)
$$('.quick-period').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.value;
if (val) select.value = String(val);
// natychmiastowy feedback w UI
$$('.quick-period').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
form.requestSubmit();
});
});
// Zmiana w <select id="period"> też auto-submituje
select.addEventListener('change', () => form.requestSubmit());
}
// Auto-inicjalizacja (analogicznie do bindQuickTop)
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', bindQuickPeriod);
} else {
bindQuickPeriod();
}
// Główne API — rysowanie wykresów
window.renderCharts = function renderCharts(stats) {
// ====== Powody (bar - poziomo) ======
{
const labels = (stats.top_reasons || []).map(r => r.reason);
const data = (stats.top_reasons || []).map(r => r.count);
if (!emptyState('reasonsChart', 'reasonsEmpty', labels, data)) {
const ctx = document.getElementById('reasonsChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{ data, backgroundColor: palette.bar2, borderRadius: 6, borderSkipped: false }]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false }, tooltip: { intersect: false, mode: 'nearest' } },
scales: {
x: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } },
y: { grid: { display: false }, ticks: { color: palette.tick } }
}
}
});
}
bindDownloads('reasonsChart', labels, data);
bindFullscreen('reasonsChart');
}
// ====== URL (bar - poziomo) ======
{
const labels = (stats.top_urls || []).map(u => u.url);
const data = (stats.top_urls || []).map(u => u.count);
if (!emptyState('urlsChart', 'urlsEmpty', labels, data)) {
const ctx = document.getElementById('urlsChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{ data, backgroundColor: palette.bar, borderRadius: 6, borderSkipped: false }]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false }, tooltip: { intersect: false, mode: 'nearest' } },
scales: {
x: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } },
y: { grid: { display: false }, ticks: { color: palette.tick } }
}
}
});
}
bindDownloads('urlsChart', labels, data);
bindFullscreen('urlsChart');
}
// ====== Kraje (pie) ======
{
const labels = (stats.top_countries || []).map(c => c.country);
const data = (stats.top_countries || []).map(c => c.count);
if (!emptyState('countriesChart', 'countriesEmpty', labels, data)) {
const ctx = document.getElementById('countriesChart').getContext('2d');
new Chart(ctx, {
type: 'pie',
data: { labels, datasets: [{ data, backgroundColor: palette.pie, borderWidth: 1, borderColor: '#222' }] },
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: palette.tick } },
tooltip: { callbacks: { label: (item) => `${item.label}: ${item.formattedValue}` } }
}
}
});
}
bindDownloads('countriesChart', labels, data);
bindFullscreen('countriesChart');
}
// ====== Bany w czasie (line) ======
{
const labels = stats.weeks || [];
const data = stats.bans_per_week || [];
if (!emptyState('bansOverTimeChart', 'timeEmpty', labels, data)) {
const ctx = document.getElementById('bansOverTimeChart').getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, palette.lineFill);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Bany / tydzień',
data,
borderColor: palette.line,
backgroundColor: gradient,
fill: true,
cubicInterpolationMode: 'monotone',
tension: 0.25,
pointRadius: 2,
pointHoverRadius: 4
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: palette.grid }, ticks: { color: palette.tick } },
y: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } }
}
}
});
}
bindDownloads('bansOverTimeChart', labels, data);
bindFullscreen('bansOverTimeChart');
}
};
})();

59
static/js/check.js Normal file
View File

@@ -0,0 +1,59 @@
(function () {
'use strict';
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
function bindErrorsFilter() {
const search = $('#errors-search');
if (!search) return;
const items = $$('#errorsAccordion .accordion-item');
const apply = () => {
const q = search.value.toLowerCase().trim();
items.forEach(it => {
const text = it.textContent.toLowerCase();
it.style.display = q && !text.includes(q) ? 'none' : '';
});
};
search.addEventListener('input', apply);
apply();
}
function bindEndpointsFilter() {
const tbody = $('#endpoints-tbody');
if (!tbody) return;
const input = $('#endpoints-search');
const empty = $('#endpoints-empty');
const applyFilter = () => {
const q = (input?.value || '').toLowerCase().trim();
let visible = 0;
$$('#endpoints-tbody tr').forEach(tr => {
const txt = tr.textContent.toLowerCase();
const show = !q || txt.includes(q);
tr.style.display = show ? '' : 'none';
if (show) visible++;
});
empty?.classList.toggle('d-none', visible !== 0);
};
input?.addEventListener('input', applyFilter);
applyFilter();
}
function init() {
bindErrorsFilter();
bindEndpointsFilter();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

93
static/js/dashboard.js Normal file
View File

@@ -0,0 +1,93 @@
(function () {
const $ = (s, r = document) => r.querySelector(s);
// --- helpers ---
function toAbsoluteUrl(u) {
try { return new URL(u, window.location.origin).href; } catch { return u; }
}
async function copyToClipboard(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) { /* fallback below */ }
// Fallback dla nieszyfrowanego HTTP / braku uprawnień
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.left = '-1000px';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
document.body.removeChild(ta);
return ok;
}
function flashButton(btn, okText = 'Skopiowano!', ms = 1200) {
if (!btn) return;
const origText = btn.textContent;
const hadSuccess = btn.classList.contains('btn-outline-success');
btn.textContent = okText;
btn.classList.add('btn-outline-success');
btn.classList.remove('btn-outline-light', 'btn-outline-secondary');
setTimeout(() => {
btn.textContent = origText;
btn.classList.toggle('btn-success', hadSuccess);
if (btn.classList.contains('copy-url')) btn.classList.add('btn-outline-light');
if (btn.classList.contains('copy-curl')) btn.classList.add('btn-outline-light');
}, ms);
}
// --- main: endpoint buttons ---
document.addEventListener('click', async (e) => {
const btnUrl = e.target.closest('.copy-url');
const btnCurl = e.target.closest('.copy-curl');
if (btnUrl) {
e.preventDefault();
e.stopPropagation();
const raw = btnUrl.dataset.url || '';
const href = toAbsoluteUrl(raw);
const ok = await copyToClipboard(href);
if (ok) {
flashButton(btnUrl);
window.showToast?.({ text: 'Skopiowano URL.', variant: 'success' });
} else {
window.showToast?.({ text: 'Nie udało się skopiować URL.', variant: 'danger' });
}
return;
}
if (btnCurl) {
e.preventDefault();
e.stopPropagation();
const raw = btnCurl.dataset.url || '';
const href = toAbsoluteUrl(raw);
const method = (btnCurl.dataset.method || 'GET').toUpperCase();
const cmd = `curl -X ${method} "${href}" -H "Accept: application/json" -sS`;
const ok = await copyToClipboard(cmd);
if (ok) {
flashButton(btnCurl);
window.showToast?.({ text: 'Skopiowano cURL.', variant: 'success' });
} else {
window.showToast?.({ text: 'Nie udało się skopiować cURL.', variant: 'danger' });
}
return;
}
});
// --- collapse label sync ---
const epCollapseEl = $('#ep-collapse');
const epToggleBtn = $('#btn-toggle-endpoints');
if (epCollapseEl && epToggleBtn && window.bootstrap) {
epCollapseEl.addEventListener('shown.bs.collapse', () => { epToggleBtn.textContent = 'Ukryj endpointy'; });
epCollapseEl.addEventListener('hidden.bs.collapse', () => { epToggleBtn.textContent = 'Pokaż endpointy'; });
}
})();

39
static/js/delete_bans.js Normal file
View File

@@ -0,0 +1,39 @@
window.addEventListener('DOMContentLoaded', () => {
const selectAll = document.getElementById('select-all');
const checks = () => Array.from(document.querySelectorAll('.row-check'));
const counter = document.getElementById('selection-counter');
const bulkBtn = document.getElementById('delete-selected');
function refreshState() {
const picked = checks().filter(c => c.checked);
counter.textContent = `${picked.length} zaznaczonych`;
bulkBtn.disabled = picked.length === 0;
}
if (selectAll) {
selectAll.addEventListener('change', () => {
checks().forEach(c => { c.checked = selectAll.checked; });
refreshState();
});
}
checks().forEach(c => c.addEventListener('change', refreshState));
refreshState();
const delAll = document.getElementById('delete-all-btn');
if (delAll) {
delAll.addEventListener('click', async () => {
if (!confirm('Na pewno usunąć WSZYSTKIE bany?')) return;
try {
const res = await fetch(window.DELETE_ALL_BANS_URL, { method: 'DELETE' });
const data = await res.json();
if (data.status === 'all_bans_removed') {
window.location.reload();
} else {
window.showToast?.({ text: 'Błąd podczas usuwania', variant: 'danger' });
}
} catch (e) {
window.showToast?.({ text: 'Błąd sieci', variant: 'danger' });
}
});
}
});

64
static/js/ip_validate.js Normal file
View File

@@ -0,0 +1,64 @@
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const ipInput = $('#ip');
const formIp = $('#reset-ip-form');
const formAll = $('#reset-all-form');
const btnConfirmIp = $('#btn-confirm-ip');
const btnConfirmAll = $('#btn-confirm-all');
const confirmIpVal = $('#confirm-ip-value');
(function initMyIp() {
const card = document.querySelector('.card[data-myip]');
const my = card?.dataset.myip?.trim();
const btn = $('#btn-myip');
if (my && btn) {
btn.disabled = false;
btn.addEventListener('click', () => {
ipInput.value = my;
ipInput.dispatchEvent(new Event('input'));
window.showToast?.({ text: `Użyto adresu: ${my}`, variant: 'dark' });
});
}
})();
// Walidacja IPv4 na żywo
const ipv4 = /^\d{1,3}(\.\d{1,3}){3}$/;
ipInput?.addEventListener('input', () => {
const ok = ipv4.test(ipInput.value.trim());
ipInput.classList.toggle('is-valid', ok);
ipInput.classList.toggle('is-invalid', ipInput.value.length > 0 && !ok);
});
// Uzupełnij wartość w modalu przed submit
document.getElementById('confirmResetIpModal')?.addEventListener('show.bs.modal', () => {
const ip = ipInput.value.trim() || '—';
confirmIpVal && (confirmIpVal.textContent = ip);
});
// Blokada przycisków na czas submitu + lekki feedback
function withSubmitting(btn, fn) {
if (!btn) return fn();
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Przetwarzanie...';
const done = () => { btn.disabled = false; btn.innerHTML = original; };
const ret = fn();
// jeśli to klasyczny submit (nawigacja), UI i tak się przeładuje
if (ret && ret.finally) ret.finally(done); else setTimeout(done, 1200);
}
// Submity (po potwierdzeniach w modalach)
formIp?.addEventListener('submit', (e) => {
if (!ipv4.test(ipInput.value.trim())) {
e.preventDefault();
ipInput.classList.add('is-invalid');
window.showToast?.({ text: 'Nieprawidłowy adres IP.', variant: 'danger' });
return;
}
withSubmitting(btnConfirmIp, () => null);
});
formAll?.addEventListener('submit', () => withSubmitting(btnConfirmAll, () => null));
})();

286
static/js/logs.js Normal file
View File

@@ -0,0 +1,286 @@
(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const logContainer = $("#logContainer");
if (!logContainer) return;
const displayLevelForm = $("#displayLevelForm");
const searchForm = $("#searchForm");
const queryInput = $("#query");
const levelSelect = $("#level");
const logScroll = $("#logScroll");
// toolbar
const btnLive = $("#btn-live");
const btnAuto = $("#btn-autoscroll");
const btnWrap = $("#btn-wrap");
const fontSelect = $("#font-size");
const btnCopy = $("#btn-copy");
const btnDownload = $("#btn-download");
// konfiguracja
const POLL_MS = 1000;
const ORDER_NEWEST_FIRST = true; // najnowsze na GÓRZE (backend już odwraca)
const TOL = 40; // tolerancja (px) od krawędzi, aby uznać, że jesteśmy „przy krawędzi”
// state
let selectedLevel = levelSelect ? levelSelect.value : "INFO";
let pollTimer = null;
let live = true;
// autoscroll: ON domyślnie
let autoScroll = true;
// gdy użytkownik ręcznie wyłączy — blokujemy automatyczne włączanie
let autoLockByUser = false;
// gdy autoscroll wyłączył się przez przewinięcie — możemy go automatycznie przywracać
let autoDisabledBy = null; // null | 'scroll' | 'user'
let wrapped = false;
let lastPayload = "";
let debounceTimer;
function setUrlParam(key, val) {
const url = new URL(window.location.href);
if (val === "" || val == null) url.searchParams.delete(key);
else url.searchParams.set(key, val);
window.history.replaceState(null, "", url.toString());
}
function highlight() {
if (window.hljs && typeof window.hljs.highlightElement === "function") {
window.hljs.highlightElement(logContainer);
}
}
function setEmptyState(on) {
$("#logEmpty")?.classList.toggle("d-none", !on);
logContainer.classList.toggle("d-none", on);
}
function refreshBtnLive() {
if (!btnLive) return;
btnLive.classList.toggle("btn-outline-light", live);
btnLive.classList.toggle("btn-danger", !live);
btnLive.textContent = live
? (btnLive.dataset.onText || "Live ON")
: (btnLive.dataset.offText || "Live OFF");
}
function refreshBtnAuto() {
if (!btnAuto) return;
btnAuto.classList.toggle("btn-outline-secondary", !autoScroll);
btnAuto.classList.toggle("btn-light", autoScroll);
btnAuto.textContent = autoScroll ? "Auto-scroll" : "Auto-scroll (OFF)";
}
function scrollToEdgeIfNeeded() {
if (!autoScroll) return;
if (ORDER_NEWEST_FIRST) {
// najnowsze na górze -> krawędź to top
logScroll.scrollTop = 0;
} else {
// klasyczny tail -> krawędź to dół
logScroll.scrollTop = logScroll.scrollHeight;
}
}
function applyText(content) {
lastPayload = content || "";
setEmptyState(!lastPayload.length);
logContainer.textContent = lastPayload;
highlight();
// tylko gdy autoscroll jest aktywny, przeskakujemy do krawędzi
scrollToEdgeIfNeeded();
}
async function copyToClipboard(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) { /* fallback below */ }
// Fallback dla HTTP/nieobsługiwanych środowisk
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.left = "-1000px";
ta.setAttribute("readonly", "");
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
let ok = false;
try {
ok = document.execCommand("copy");
} catch (_) {
ok = false;
}
document.body.removeChild(ta);
return ok;
}
function loadLogs({ silent = false } = {}) {
const query = queryInput ? queryInput.value : "";
const url = `/logs-data?level=${encodeURIComponent(selectedLevel)}&query=${encodeURIComponent(query)}`;
return fetch(url)
.then(res => res.ok ? res.json() : Promise.reject(res.status))
.then(data => {
// backend już zwraca newest-first; nie odwracaj kolejności
const lines = Array.isArray(data.logs) ? data.logs.slice() : [];
const text = lines.join("\n");
applyText(text);
})
.catch(() => {
if (!silent) window.showToast?.({ text: "Błąd ładowania logów", variant: "danger" });
});
}
function startPolling() {
stopPolling();
if (!live) return;
pollTimer = setInterval(() => loadLogs({ silent: true }), POLL_MS);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
}
// wyszukiwarka — enter lub debounce 400ms
searchForm?.addEventListener("submit", (e) => {
e.preventDefault();
setUrlParam("query", queryInput.value || "");
loadLogs();
});
queryInput?.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
setUrlParam("query", queryInput.value || "");
loadLogs({ silent: true });
}, 400);
});
// --- TOOLBAR ---
btnLive?.addEventListener("click", () => {
live = !live;
refreshBtnLive();
window.showToast?.({
text: live ? "Live tail: włączony" : "Live tail: wyłączony",
variant: live ? "dark" : "warning"
});
live ? startPolling() : stopPolling();
});
refreshBtnLive();
btnAuto?.addEventListener("click", () => {
// ręczne przełączenie blokuje/odblokowuje auto włączanie
autoScroll = !autoScroll;
autoLockByUser = !autoScroll; // jeśli wyłączasz przyciskiem — zablokuj auto re-enable
autoDisabledBy = autoScroll ? null : "user";
refreshBtnAuto();
window.showToast?.({
text: autoScroll ? "Auto-scroll: ON" : "Auto-scroll: OFF (ręcznie)",
variant: "info"
});
if (autoScroll) {
// po włączeniu — przeskocz do krawędzi
scrollToEdgeIfNeeded();
}
});
refreshBtnAuto();
btnWrap?.addEventListener("click", () => {
wrapped = !wrapped;
btnWrap.classList.toggle("btn-outline-secondary", !wrapped);
btnWrap.classList.toggle("btn-light", wrapped);
logContainer.style.whiteSpace = wrapped ? "pre-wrap" : "pre";
});
fontSelect?.addEventListener("change", () => {
logContainer.style.fontSize = fontSelect.value;
});
btnCopy?.addEventListener("click", async () => {
const ok = await copyToClipboard(lastPayload || "");
window.showToast?.({
text: ok ? "Skopiowano log do schowka." : "Nie udało się skopiować.",
variant: ok ? "success" : "danger"
});
});
btnDownload?.addEventListener("click", () => {
const blob = new Blob([lastPayload || ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `app.log.view.txt`; a.click();
URL.revokeObjectURL(url);
});
// --- SMART AUTOSCROLL GUARD ---
// 1) auto-wyłączenie gdy oddalimy się od krawędzi
// 2) auto-włączenie gdy wrócimy do krawędzi, ale tylko jeśli nie zablokowano przyciskiem
let scrollTicking = false; // micro-raf guard
logScroll.addEventListener("scroll", () => {
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
scrollTicking = false;
if (ORDER_NEWEST_FIRST) {
// krawędź to góra (top)
const atTop = logScroll.scrollTop <= TOL;
const farFromTop = logScroll.scrollTop > TOL;
// 1) gdy uciekasz od góry — wyłącz auto, jeśli było włączone
if (farFromTop && autoScroll) {
autoScroll = false;
autoDisabledBy = "scroll";
refreshBtnAuto();
window.showToast?.({ text: "Auto-scroll: OFF (przewijanie)", variant: "info" });
}
// 2) gdy wracasz na samą górę — automatycznie włącz, jeśli nie ma blokady usera
if (atTop && !autoScroll && !autoLockByUser && autoDisabledBy === "scroll") {
autoScroll = true;
autoDisabledBy = null;
refreshBtnAuto();
// upewnij się, że trzymamy top
logScroll.scrollTop = 0;
window.showToast?.({ text: "Auto-scroll: ON (powrót na górę)", variant: "info" });
}
} else {
// klasyczny tail — krawędź to dół
const distanceFromBottom = logScroll.scrollHeight - logScroll.clientHeight - logScroll.scrollTop;
const atBottom = distanceFromBottom <= TOL;
const farFromBottom = distanceFromBottom > TOL;
if (farFromBottom && autoScroll) {
autoScroll = false;
autoDisabledBy = "scroll";
refreshBtnAuto();
window.showToast?.({ text: "Auto-scroll: OFF (przewijanie)", variant: "info" });
}
if (atBottom && !autoScroll && !autoLockByUser && autoDisabledBy === "scroll") {
autoScroll = true;
autoDisabledBy = null;
refreshBtnAuto();
logScroll.scrollTop = logScroll.scrollHeight;
window.showToast?.({ text: "Auto-scroll: ON (powrót na dół)", variant: "info" });
}
}
});
}, { passive: true });
// Start
loadLogs().then(() => startPolling());
// sprzątanie
window.addEventListener("beforeunload", () => {
stopPolling();
});
})();

View File

@@ -0,0 +1,16 @@
document.getElementById('setLogLevelForm').addEventListener('submit', function(e) {
e.preventDefault();
const level = document.getElementById('appLogLevel').value;
fetch(SET_LOG_LEVEL_URL, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'level=' + encodeURIComponent(level)
})
.then(res => res.json())
.then(data => {
window.showToast({ text: data.message, variant: 'success' });
})
.catch(() => {
window.showToast({ text: "Błąd podczas zmiany poziomu", variant: "error" });
});
});

72
static/js/stats.js Normal file
View File

@@ -0,0 +1,72 @@
(function () {
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
// Kopiuj: cały widok
$('#btn-copy-overview')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(document.body.innerText);
window.showToast?.({ text: 'Skopiowano podsumowanie.', variant: 'success' });
} catch {
window.showToast?.({ text: 'Nie udało się skopiować.', variant: 'danger' });
}
});
// Eksport CSV z DOM (GEO + REASONS)
$('#btn-export-overview')?.addEventListener('click', () => {
const lines = [];
lines.push('section,key,count,percent');
// GEO: z tabeli
$$('#geo-table tbody tr').forEach(tr => {
const tds = tr.querySelectorAll('td');
if (tds.length !== 3) return;
const country = tds[0]?.innerText.trim();
const percent = (tds[1]?.querySelector('.text-secondary')?.innerText.trim() || '').replace('%', '');
const count = tds[2]?.innerText.trim();
if (country) lines.push(`geo,"${country.replace(/"/g, '""')}",${count},${percent}`);
});
// REASONS: z listy
$$('.card:has(.card-header:contains("Przyczyny banów")) .list-group-item').forEach(li => {
const label = li.querySelector('.text-truncate')?.getAttribute('title') || li.querySelector('.text-truncate')?.innerText || '';
const meta = li.querySelector('.small.text-secondary')?.innerText || ''; // "123 (45.6%)"
const m = meta.match(/(\d+)\s*\(([\d.,]+)%\)/);
const count = m ? m[1] : '';
const percent = m ? m[2].replace(',', '.') : '';
if (label) lines.push(`reason,"${label.replace(/"/g, '""')}",${count},${percent}`);
});
const csv = lines.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'stats_overview.csv'; a.click();
URL.revokeObjectURL(url);
});
// Polyfill :contains dla selektora użytego wyżej (prosty, lokalny)
(function addContainsPseudo() {
const _matches = Element.prototype.matches;
if (!document.querySelector(':contains(dummy)')) {
const oldQuerySelectorAll = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function (sel) {
if (!sel.includes(':contains(')) return oldQuerySelectorAll.call(this, sel);
const m = sel.match(/^(.*):has\(\.card-header:contains\("([^"]+)"\)\)\s*(.*)$/);
if (m) {
const [, pre, text, post] = m;
return $$(pre + ' .card').filter(card => {
return card.querySelector('.card-header')?.textContent.includes(text);
}).flatMap(card => card.querySelectorAll(post || ''));
}
return oldQuerySelectorAll.call(this, sel);
};
Element.prototype.matches = function (sel) {
if (!sel.includes(':contains(')) return _matches.call(this, sel);
const m = sel.match(/^:contains\("([^"]+)"\)$/);
if (m) return this.textContent.includes(m[1]);
return _matches.call(this, sel);
};
}
})();
})();

35
static/js/toast.js Normal file
View File

@@ -0,0 +1,35 @@
(function () {
const toastEl = document.getElementById('appToast');
const bodyEl = document.getElementById('appToastBody');
const variants = {
success: 'text-bg-success',
error: 'text-bg-danger',
danger: 'text-bg-danger',
warning: 'text-bg-warning',
info: 'text-bg-info',
dark: 'text-bg-dark'
};
function setVariant(el, variant) {
Object.values(variants).forEach(v => el.classList.remove(v));
el.classList.add(variants[variant] || variants.dark);
}
window.showToast = function ({ text = 'Gotowe.', variant = 'dark' } = {}) {
if (bodyEl) bodyEl.textContent = text;
setVariant(toastEl, variant);
const t = new bootstrap.Toast(toastEl);
t.show();
};
const flashNode = document.getElementById('flash-data');
if (flashNode) {
try {
const flashes = JSON.parse(flashNode.dataset.flashes || '[]');
flashes.forEach(([category, message]) => {
window.showToast({ text: message, variant: category });
});
} catch (_) { }
}
})();

82
templates/base.html Normal file
View File

@@ -0,0 +1,82 @@
</html>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{% block title %}Panel{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
{% block head %}{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100">
<header class="border-bottom border-secondary-subtle">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark" aria-label="Główna nawigacja">
<div class="container-xxl">
<a class="navbar-brand fw-semibold text-primary" href="{{ url_for('index') }}">autoban</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"
aria-controls="mainNav" aria-label="Menu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link {% if request.endpoint=='index' %}active{% endif %}"
href="{{ url_for('index') }}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='ban_management' %}active{% endif %}"
href="{{ url_for('ban_management') }}">Zarządzanie Banami</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='stats_page' %}active{% endif %}"
href="{{ url_for('stats_page') }}">Statystyki</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='charts_page' %}active{% endif %}"
href="{{ url_for('charts_page') }}">Wykresy</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='view_logs' %}active{% endif %}"
href="{{ url_for('view_logs') }}">Logi</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='check_ip_info' %}active{% endif %}"
href="{{ url_for('check_ip_info') }}">Sprawdź IP</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='reset_counters' %}active{% endif %}"
href="{{ url_for('reset_counters') }}">Reset błędów</a></li>
</ul>
<form class="d-flex" action="{{ url_for('check_ip_info') }}" method="post" role="search">
<input class="form-control form-control-sm bg-dark text-white border-secondary" name="ip"
placeholder="Szybkie IP" inputmode="numeric" pattern="\d{1,3}(\.\d{1,3}){3}">
</form>
</div>
</div>
</nav>
</header>
<main id="main" class="container-xxl flex-grow-1 py-3">
{% block content %}{% endblock %}
</main>
<footer class="border-top border-secondary-subtle mt-auto bg-dark">
<div class="container-xxl py-3 text-secondary small d-flex flex-wrap gap-2 justify-content-between">
<span>linuxiarz.pl</span>
<a class="link-secondary" href="{{ url_for('healthcheck') }}">Status</a>
</div>
</footer>
{# GLOBALNY TOAST (w całej aplikacji) #}
<div class="position-fixed end-0 bottom-0 p-3" style="z-index:1080;">
<div id="appToast" class="toast text-bg-dark border border-secondary" role="status" aria-live="polite"
aria-atomic="true">
<div class="toast-header bg-dark text-white border-bottom border-secondary">
<strong class="me-auto">{{ app_name or "Ban Manager" }}</strong>
<small>Teraz</small>
<button class="btn-close btn-close-white ms-2 mb-1" data-bs-dismiss="toast" aria-label="Zamknij"></button>
</div>
<div class="toast-body" id="appToastBody">Gotowe.</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/toast.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>

182
templates/charts.html Normal file
View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Wykresy{% endblock %}
{% block content %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h3 text-white mb-1">Wykresy</h1>
<p class="text-secondary mb-0">Podgląd trendów, najczęstszych powodów i źródeł banów.</p>
</div>
<form id="options-form" method="get" class="d-flex align-items-center gap-2">
<label for="top_n" class="text-secondary small">TOP:</label>
<select id="top_n" name="top_n" class="form-select form-select-sm bg-dark text-white border-secondary"
style="width:auto">
{% for n in [5,10,25,50,100] %}
<option value="{{ n }}" {% if n==top_n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<label for="period" class="text-secondary small ms-2">Okres:</label>
<select id="period" name="period" class="form-select form-select-sm bg-dark text-white border-secondary"
style="width:auto">
{% for p in [("week","Tydzień"),("month","Miesiąc"),("year","Rok")] %}
<option value="{{ p[0] }}" {% if p[0]==period %}selected{% endif %}>{{ p[1] }}</option>
{% endfor %}
</select>
<div class="btn-group btn-group-sm ms-1" role="group" aria-label="Szybkie TOP">
{% for n in [5,10,25,50,100] %}
<button type="button" class="btn btn-outline-secondary quick-top {% if n == top_n %}active{% endif %}"
data-value="{{ n }}">{{ n }}</button>
{% endfor %}
</div>
<div class="btn-group btn-group-sm ms-1" role="group" aria-label="Szybki okres">
{% for p in [("week","Tydzień"),("month","Miesiąc"),("year","Rok")] %}
<button type="button" class="btn btn-outline-secondary quick-period {% if p[0] == period %}active{% endif %}"
data-value="{{ p[0] }}">{{ p[1] }}</button>
{% endfor %}
</div>
<!-- <button type="submit" class="btn btn-primary btn-sm ms-1">Zastosuj</button> -->
</form>
</div>
<!-- skeleton podczas ładowania -->
<div id="charts-skeleton" class="placeholder-glow mb-3">
<span class="placeholder col-12"></span>
<span class="placeholder col-12"></span>
<span class="placeholder col-12"></span>
</div>
<div id="charts-root" class="d-none">
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Top {{ top_n }} powody banów</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary chart-download" data-target="reasonsChart" data-type="png">PNG</button>
<button class="btn btn-outline-secondary chart-download" data-target="reasonsChart" data-type="csv">CSV</button>
<button class="btn btn-outline-secondary chart-fullscreen" data-target="reasonsChart">Fullscreen</button>
</div>
</div>
<div class="card-body">
<canvas id="reasonsChart" height="120"></canvas>
<div class="text-center text-secondary small d-none" id="reasonsEmpty">Brak danych do wyświetlenia.</div>
</div>
</div>
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Top {{ top_n }} URL (bany)</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary chart-download" data-target="urlsChart" data-type="png">PNG</button>
<button class="btn btn-outline-secondary chart-download" data-target="urlsChart" data-type="csv">CSV</button>
<button class="btn btn-outline-secondary chart-fullscreen" data-target="urlsChart">Fullscreen</button>
</div>
</div>
<div class="card-body">
<canvas id="urlsChart" height="120"></canvas>
<div class="text-center text-secondary small d-none" id="urlsEmpty">Brak danych do wyświetlenia.</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Kraje pochodzenia (bany)</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary chart-download" data-target="countriesChart"
data-type="png">PNG</button>
<button class="btn btn-outline-secondary chart-download" data-target="countriesChart"
data-type="csv">CSV</button>
<button class="btn btn-outline-secondary chart-fullscreen" data-target="countriesChart">Fullscreen</button>
</div>
</div>
<div class="card-body d-flex justify-content-center">
<canvas id="countriesChart" style="max-width: 340px; max-height: 340px;"></canvas>
</div>
<div class="text-center text-secondary small d-none" id="countriesEmpty">Brak danych do wyświetlenia.</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header">Legenda krajów</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for item in stats.top_countries %}
<li
class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center border-secondary">
{{ item.country }}
<span class="badge bg-primary rounded-pill">{{ item.count }}</span>
</li>
{% endfor %}
{% if stats.top_countries|length == 0 %}
<li class="list-group-item bg-dark text-secondary border-secondary text-center">Brak danych</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
<div class="card bg-dark border-secondary my-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Bany w czasie (ostatnie 6 tygodni)</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary chart-download" data-target="bansOverTimeChart"
data-type="png">PNG</button>
<button class="btn btn-outline-secondary chart-download" data-target="bansOverTimeChart"
data-type="csv">CSV</button>
<button class="btn btn-outline-secondary chart-fullscreen" data-target="bansOverTimeChart">Fullscreen</button>
</div>
</div>
<div class="card-body">
<canvas id="bansOverTimeChart" height="120"></canvas>
<div class="text-center text-secondary small d-none" id="timeEmpty">Brak danych do wyświetlenia.</div>
</div>
</div>
</div>
<!-- Modal fullscreen dla dowolnego canvas -->
<div class="modal fade" id="chartModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">Podgląd wykresu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body d-flex align-items-center justify-content-center">
<canvas id="chartModalCanvas" style="max-width:95vw; max-height:80vh;"></canvas>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6"></script>
<script>
const statsData = {{ stats | tojson }};
document.getElementById('top_n')?.addEventListener('change', () => {
document.getElementById('options-form').requestSubmit();
});
window.addEventListener('DOMContentLoaded', () => {
const sk = document.getElementById('charts-skeleton');
const root = document.getElementById('charts-root');
try {
renderCharts(statsData);
sk?.classList.add('d-none');
root?.classList.remove('d-none');
} catch (e) {
window.showToast?.({ text: 'Błąd renderowania wykresów', variant: 'danger' });
console.error(e);
}
});
</script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
{% endblock %}

190
templates/check.html Normal file
View File

@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}Sprawdź IP{% endblock %}
{% block content %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h3 text-white mb-1">Sprawdź IP</h1>
<p class="text-secondary mb-0">Szybka diagnostyka adresu — logi, endpointy i metryki powiązane z IP.</p>
</div>
</div>
<!-- Formularz -->
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Wprowadź IP do sprawdzenia</span>
</div>
<div class="card-body">
<form method="post" id="reset-ip-form" novalidate>
<div class="mb-3 col-sm-6 col-md-5 col-lg-4">
<label for="ip" class="form-label text-primary">Adres IPv4</label>
<input id="ip" name="ip" class="form-control bg-dark text-white border-secondary" inputmode="numeric"
autocomplete="off" required placeholder="np. 185.12.34.56" value="{{ ip }}" pattern="\d{1,3}(\.\d{1,3}){3}">
<div class="form-text text-primary"><code>Format: xxx.xxx.xxx.xxx</code></div>
<div class="invalid-feedback">Podaj poprawny adres IPv4.</div>
</div>
<div class="col-12">
<button id="btn-confirm-ip" type="submit" class="btn btn-outline-primary">Sprawdź</button>
</div>
</form>
</div>
</div>
<!-- Ostatnie błędy -->
{% if recent_errors %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Ostatnie błędy</span>
<div class="input-group input-group-sm" style="width: 260px;">
<span class="input-group-text bg-dark border-secondary text-secondary">🔎</span>
<input id="errors-search" type="search" class="form-control bg-dark border-secondary text-white"
placeholder="Filtruj błędy…">
</div>
</div>
<div class="card-body">
<div class="accordion" id="errorsAccordion">
{% for error_entry in recent_errors %}
<div class="accordion-item bg-transparent border-secondary">
<h2 class="accordion-header" id="err-h-{{ loop.index }}">
<button class="accordion-button collapsed bg-dark text-white border-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#err-c-{{ loop.index }}" aria-expanded="false"
aria-controls="err-c-{{ loop.index }}">
<span class="me-2 badge bg-danger">#{{ loop.index }}</span>
<span class="error-title text-truncate" style="max-width: 70%;">{{ error_entry[:120] }}{% if
error_entry|length > 120 %}…{% endif %}</span>
</button>
</h2>
<div id="err-c-{{ loop.index }}" class="accordion-collapse collapse" aria-labelledby="err-h-{{ loop.index }}"
data-bs-parent="#errorsAccordion">
<div class="accordion-body">
<pre class="mb-2 text-white small" style="white-space: pre-wrap;">{{ error_entry }}</pre>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Endpointy -->
{% if endpoints %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Zalogowane endpointy <span class="text-secondary">( {{ endpoints|length }} )</span></span>
<div class="d-flex align-items-center gap-2">
<div class="input-group input-group-sm" style="width: 320px;">
<span class="input-group-text bg-dark border-secondary text-secondary">🔎</span>
<input id="endpoints-search" type="search" class="form-control bg-dark border-secondary text-white"
placeholder="Filtruj endpointy…">
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:64px">#</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody id="endpoints-tbody">
{% for endpoint in endpoints %}
<tr>
<td class="text-secondary">{{ loop.index }}</td>
<td>
<span class="d-inline-block text-truncate" style="max-width: 70%;" title="{{ endpoint }}">{{ endpoint
}}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="endpoints-empty" class="text-center text-secondary small d-none py-4">Brak wyników dla podanego filtra
</div>
</div>
</div>
</div>
{% endif %}
<!-- Metryki -->
{% if metrics %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">Metryki</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark align-middle mb-0">
<thead>
<tr>
<th>Klucz</th>
<th class="text-end">Wartość</th>
</tr>
</thead>
<tbody>
{% for key, value in metrics.items() %}
<tr>
<td class="text-secondary">{{ key }}</td>
<td class="text-end">
{% if value is number %}
<span class="badge bg-primary">{{ value }}</span>
{% else %}
<span class="badge bg-secondary">{{ value }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Status bana -->
{% if is_banned %}
<div class="card bg-dark border-danger mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="text-danger">Status: IP jest ZBANOWANE</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark align-middle mb-0">
<tbody>
<tr>
<td class="text-secondary" style="width:220px">Powód</td>
<td>{{ ban_info.reason or ban_info.source or "brak danych" }}</td>
</tr>
<tr>
<td class="text-secondary">Źródło</td>
<td>{{ ban_info.source or "-" }}</td>
</tr>
<tr>
<td class="text-secondary">Utworzono</td>
<td>{{ ban_info.created_at or ban_info.timestamp or "-" }}</td>
</tr>
<tr>
<td class="text-secondary">Wygasa</td>
<td>{{ ban_info.expires or "-" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/check.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/ip_validate.js') }}" defer></script>
{% if message %}
<script>window.addEventListener('DOMContentLoaded', () => window.showToast?.({ text: {{ message| tojson }}, variant: 'success' }));</script>
{% endif %}
{% if error %}
<script>window.addEventListener('DOMContentLoaded', () => window.showToast?.({ text: {{ error| tojson }}, variant: 'danger' }));</script>
{% endif %}
{% endblock %}

161
templates/index.html Normal file
View File

@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h3 text-white mb-1">Dashboard</h1>
<p class="text-secondary mb-0">Szybki podgląd stanu systemu i dostępnych endpointów.</p>
</div>
</div>
<!-- KPI tiles -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body">
<div class="text-secondary small">Aktywne bany</div>
<div class="display-6 fw-bold text-white">{{ stats.system.active_bans|int }}</div>
<div class="text-secondary small">ogółem</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body">
<div class="text-secondary small">Drupal attacks</div>
<div class="display-6 fw-bold text-white">{{ stats.system.drupal_attacks|int }}</div>
<div class="text-secondary small">wykryte łącznie</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body">
<div class="text-secondary small">Uptime</div>
<div class="h3 fw-bold text-white mb-0">{{ stats.system.uptime }}</div>
<div class="text-secondary small">czas działania</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body">
<div class="text-secondary small">Zużycie pamięci</div>
<div class="h3 fw-bold text-white mb-0">{{ stats.system.memory_usage }}</div>
<div class="text-secondary small">aktualnie</div>
</div>
</div>
</div>
</div>
<!-- System -->
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">Informacje o systemie</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0" id="sys-table">
<tbody>
{% for key, value in sys_info.system.items() %}
<tr>
<th style="width:30%;" class="text-secondary">{{ key | capitalize }}</th>
<td class="text-white">{{ value }}</td>
</tr>
{% endfor %}
{% if sys_info.system|length == 0 %}
<tr>
<td colspan="2" class="text-center text-secondary">Brak danych</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Aplikacja -->
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">Informacje o aplikacji</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0" id="app-table">
<tbody>
{% for key, value in sys_info.application.items() %}
<tr>
<th style="width:30%;" class="text-secondary">{{ key | capitalize }}</th>
<td class="text-white">{{ value }}</td>
</tr>
{% endfor %}
{% if sys_info.application|length == 0 %}
<tr>
<td colspan="2" class="text-center text-secondary">Brak danych</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Endpointy — domyślnie zwinięte -->
<div class="card bg-dark border-secondary">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>Dostępne endpointy API <span class="text-secondary">({{ routes|length }})</span></span>
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#ep-collapse"
aria-expanded="false" aria-controls="ep-collapse" id="btn-toggle-endpoints">
Pokaż endpoint
</button>
</div>
<div id="ep-collapse" class="collapse">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0" id="ep-table">
<thead>
<tr>
<th style="width:20px">#</th>
<th>URL</th>
<th>Metody</th>
<th class="text-end">Akcje</th>
</tr>
</thead>
<tbody>
{% for route in routes %}
<tr>
<td class="text-secondary">{{ loop.index }}</td>
<td>
<a href="{{ route.url }}" class="text-white text-decoration-none" target="_blank" rel="noopener">{{
route.url }}</a>
</td>
<td>
{% for m in route.methods.split(',') if route.methods %}
<span class="badge bg-secondary me-1">{{ m.strip() }}</span>
{% endfor %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-light copy-url" data-url="{{ route.url }}">Kopiuj URL</button>
<button class="btn btn-outline-light copy-curl" data-url="{{ route.url }}"
data-method="{{ (route.methods.split(',')[0] if route.methods else 'GET')|trim }}">
Kopiuj cURL
</button>
</div>
</td>
</tr>
{% endfor %}
{% if routes|length == 0 %}
<tr>
<td colspan="4" class="text-center text-secondary">Brak endpointów</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/dashboard.js') }}" defer></script>
{% endblock %}

205
templates/list.html Normal file
View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Zarządzanie banami{% endblock %}
{% block content %}
<div class="container-fluid px-0 px-md-2">
<!-- Header z wyszukiwarką i przyciskiem Nowy ban -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h3 text-white mb-1">Zarządzanie banami</h1>
<p class="text-secondary mb-0">Dodawaj, przeglądaj i usuwaj blokady IP. Szybkie wyszukiwanie, selekcja zbiorcza i
podgląd szczegółów.</p>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-light" data-bs-toggle="offcanvas" data-bs-target="#newBanOffcanvas"
aria-controls="newBanOffcanvas">
+ Dodaj
</button>
<div class="vr d-none d-md-block"></div>
<div class="input-group input-group-sm" style="min-width: 260px;">
<span class="input-group-text bg-dark border-secondary text-secondary">🔎</span>
<input id="search-input" type="search" class="form-control bg-dark border-secondary text-white"
placeholder="Szukaj po IP, hostname, powodzie…" aria-label="Szukaj">
</div>
</div>
</div>
<!-- Pasek narzędzi + jeden wspólny formularz dla selekcji zbiorczej -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-body d-flex flex-wrap align-items-center gap-2">
<div class="me-auto d-flex align-items-center gap-3">
<span id="selection-counter" class="text-secondary small">0 zaznaczonych</span>
<span class="text-secondary small">Aktualne bany: <span class="text-white fw-semibold">{{ banned_ips|length
}}</span></span>
</div>
<div class="btn-group" role="group" aria-label="Akcje zbiorcze">
<!-- Przycisk zbiorczego usuwania zwiążemy z formularzem poniżej -->
<button type="submit" name="delete" id="delete-selected" class="btn btn-outline-danger" form="bulk-form" disabled>
Usuń zaznaczone
</button>
<button type="button" id="delete-all-btn" class="btn btn-outline-danger">Usuń wszystkie</button>
</div>
</div>
</div>
<!-- Jeden wspólny formularz obejmujący tabelę z checkboxami -->
<form id="bulk-form" method="post" class="mb-0">
<div class="card bg-dark border-secondary">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead class="position-sticky top-0" style="z-index: 1;">
<tr class="border-secondary">
<th style="width:42px">
<input type="checkbox" id="select-all" aria-label="Zaznacz/odznacz wszystkie">
</th>
<th class="text-uppercase text-secondary small">IP</th>
<th class="text-uppercase text-secondary small">Hostname</th>
<th class="text-uppercase text-secondary small">Powód</th>
<th class="text-uppercase text-secondary small">Wygasa</th>
<th class="text-uppercase text-secondary small text-end" style="width:80px">Akcje</th>
</tr>
</thead>
<tbody id="bans-tbody">
{% if banned_ips|length == 0 %}
<tr>
<td colspan="6" class="text-center py-5 text-secondary">
Brak aktywnych banów. Użyj przycisku <span class="text-white">“Nowy ban”</span>, aby dodać pierwszy
wpis.
</td>
</tr>
{% else %}
{% for ban in banned_ips %}
<tr class="ban-row">
<td>
<input type="checkbox" class="row-check" name="selected_ips" value="{{ ban.ip }}"
aria-label="Zaznacz {{ ban.ip }}">
</td>
<td>
<button class="btn btn-link p-0 text-decoration-none ban-ip text-white" data-ip="{{ ban.ip }}"
type="button">
{{ ban.ip }}
</button>
</td>
<td>
{% if ban.hostname and ban.hostname != 'Manual ban' %}
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">{{ ban.hostname
}}</span>
{% else %}
<span class="text-primary">Brak</span>
{% endif %}
</td>
<td>
{% if ban.reason %}{{ ban.reason }}{% else %}<span class="text-secondary">Manual ban</span>{% endif %}
</td>
<td><span class="expires-text">{{ ban.expires }}</span></td>
<td class="text-end">
<!-- Pojedyncze usunięcie: oddzielny mini-form w wierszu -->
<form method="post" class="d-inline">
<input type="hidden" name="selected_ips" value="{{ ban.ip }}">
<button type="submit" name="delete" value="1" class="btn btn-sm btn-outline-danger"
title="Usuń ten ban">Usuń</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</form>
</div>
<!-- Offcanvas: Nowy ban -->
<div class="offcanvas offcanvas-end text-bg-dark" tabindex="-1" id="newBanOffcanvas" aria-labelledby="newBanLabel">
<div class="offcanvas-header border-bottom border-secondary">
<h5 class="offcanvas-title" id="newBanLabel">Dodaj nowy ban</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Zamknij"></button>
</div>
<div class="offcanvas-body">
<form method="post" id="add-ban-form" novalidate>
<input type="hidden" name="add_ban">
<div class="mb-3">
<label for="ip" class="form-label">Adres IP</label>
<input id="ip" name="ip" inputmode="numeric" autocomplete="off"
class="form-control bg-dark text-white border-secondary" required placeholder="np. 192.168.0.10"
pattern="\d{1,3}(\.\d{1,3}){3}">
<code>Format: xxx.xxx.xxx.xxx</code>
<div class="invalid-feedback">Podaj poprawny adres IPv4.</div>
</div>
<div class="mb-3">
<label for="reason" class="form-label">Powód <span class="text-secondary">(opcjonalnie)</span></label>
<input id="reason" name="reason" class="form-control bg-dark text-white border-secondary"
placeholder="np. brute force / abuse">
</div>
<div class="mb-3">
<label for="duration" class="form-label">Czas trwania</label>
<select id="duration" name="duration" class="form-select bg-dark text-white border-secondary">
<option value="3600">1 godzina</option>
<option value="86400">1 dzień</option>
<option value="604800">1 tydzień</option>
<option value="2592000">1 miesiąc</option>
<option value="5184000">2 miesiące</option>
<option value="7776000">3 miesiące</option>
<option value="10368000">4 miesiące</option>
<option value="12960000">5 miesięcy</option>
<option value="15552000">6 miesięcy</option>
<option value="18144000">7 miesięcy</option>
<option value="20736000">8 miesięcy</option>
<option value="23328000">9 miesięcy</option>
<option value="25920000">10 miesięcy</option>
<option value="28512000">11 miesięcy</option>
<option value="31536000">1 rok</option>
<option value="63072000">2 lata</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-primary">Dodaj ban</button>
</div>
</form>
</div>
</div>
<!-- Modal: Szczegóły bana -->
<div class="modal fade" id="banModal" tabindex="-1" aria-labelledby="banModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="banModalLabel">Szczegóły bana</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body" id="banModalBody">
<div class="placeholder-glow">
<span class="placeholder col-12"></span>
<span class="placeholder col-10"></span>
<span class="placeholder col-8"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/bans.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/delete_bans.js') }}" defer></script>
{% if message %}
<script>
window.addEventListener('DOMContentLoaded', () => {
window.showToast({ text: {{ message| tojson }}, variant: 'success' });
});
</script>
{% endif %}
{% if error %}
<script>
window.addEventListener('DOMContentLoaded', () => {
window.showToast({ text: {{ error| tojson }}, variant: 'danger' });
});
</script>
{% endif %}
{% endblock %}

107
templates/logs.html Normal file
View File

@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}Podgląd logów{% endblock %}
{% block content %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<div>
<h1 class="h3 text-white mb-1">Podgląd logów</h1>
<p class="text-secondary mb-0">Podgląd pliku <code>app.log</code> z podświetlaniem i live tail.</p>
</div>
</div>
<!-- Zmień poziom logowania aplikacji -->
<div class="card bg-dark border-warning mb-2">
<div class="card-body d-flex align-items-center gap-2">
<form id="setLogLevelForm" method="post" action="{{ url_for('set_log_level') }}" class="d-flex align-items-center gap-2">
<label for="appLogLevel" class="text-warning small me-2">Poziom logowania aplikacji:</label>
<select name="level" id="appLogLevel" class="form-select form-select-sm bg-dark text-warning border-warning me-2" style="width:120px;">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
<button type="submit" class="btn btn-outline-warning btn-sm">Ustaw</button>
</form>
<span id="setLogLevelStatus" class="text-warning ms-2 small"></span>
</div>
</div>
<!-- Toolbar -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-body d-flex flex-wrap align-items-center gap-2">
<!-- Poziom wyświetlania -->
<form method="get" id="displayLevelForm" action="{{ url_for('view_logs') }}"
class="d-flex align-items-center gap-2 me-auto">
<label class="text-secondary small me-2">Poziom:</label>
<div class="btn-group btn-group-sm" role="group" aria-label="Szybki poziom">
{% for lvl in ['DEBUG','INFO','WARNING','ERROR','CRITICAL'] %}
<button type="submit" class="btn btn-outline-secondary{% if lvl==selected_level %} active{% endif %}"
name="level" value="{{ lvl }}">
{{ lvl }}
</button>
{% endfor %}
</div>
</form>
<!-- Wyszukiwarka -->
<form method="get" id="searchForm" action="{{ url_for('view_logs') }}" class="d-flex align-items-center gap-1">
<input type="hidden" name="level" value="{{ selected_level }}">
<div class="input-group input-group-sm" style="min-width:280px;">
<span class="input-group-text bg-dark border-secondary text-secondary">🔎</span>
<input type="search" name="query" id="query" class="form-control bg-dark text-white border-secondary"
placeholder="Szukaj (regexp/grep)..." value="{{ request.args.get('query','') }}">
<button type="submit" class="btn btn-outline-secondary">Szukaj</button>
</div>
</form>
<!-- Przełączniki -->
<div class="btn-group btn-group-sm ms-1" role="group" aria-label="Widok">
<button id="btn-live" class="btn btn-outline-light" data-on-text="Live ON" data-off-text="Live OFF">Live
ON</button>
<button id="btn-autoscroll" class="btn btn-outline-secondary">Auto-scroll</button>
<button id="btn-wrap" class="btn btn-outline-secondary">Zawijaj</button>
</div>
<!-- Czcionka -->
<div class="input-group input-group-sm ms-1" style="width:140px;">
<span class="input-group-text bg-dark border-secondary text-secondary">A↕</span>
<select id="font-size" class="form-select bg-dark text-white border-secondary">
{% for size in ['12px','13px','14px','15px','16px'] %}
<option value="{{ size }}" {% if size=='14px' %}selected{% endif %}>{{ size }}</option>
{% endfor %}
</select>
</div>
<!-- Akcje -->
<label class="text-secondary small me-2">Akcje:</label>
<div class="btn-group btn-group-sm ms-1" role="group" aria-label="Akcje">
<button id="btn-copy" class="btn btn-outline-secondary">Kopiuj</button>
<button id="btn-download" class="btn btn-outline-secondary">Pobierz</button>
</div>
</div>
</div>
<!-- Log viewer -->
<div class="card bg-dark border-secondary">
<div class="card-body p-0" style="max-height: 70vh; overflow: auto;" id="logScroll">
<pre id="logContainer" class="bg-dark text-white hljs mb-0"
style="font-size:14px; line-height:1.35; padding: 1rem; border-radius: 0; white-space: pre; tab-size: 2;"></pre>
<div id="logEmpty" class="text-center text-secondary small py-4 d-none">Brak danych do wyświetlenia.</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<!-- Highlight.js -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/monokai.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
SET_LOG_LEVEL_URL = "{{ url_for('set_log_level') }}";
</script>
<script defer src="{{ url_for('static', filename='js/logs.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/set_log_level.js') }}"></script>
{% endblock %}

99
templates/reset.html Normal file
View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}Reset liczników{% endblock %}
{% block content %}
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h3 text-white mb-1">Reset liczników</h1>
<p class="text-secondary mb-0">Wyzeruj liczniki błędów globalnie lub dla konkretnego adresu IP.</p>
</div>
</div>
<!-- Reset dla konkretnego IP -->
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Reset liczników dla IP</span>
</div>
<div class="card-body">
<form method="post" id="reset-ip-form" novalidate>
<input type="hidden" name="reset_ip">
<div class="mb-3 col-sm-6 col-md-5 col-lg-4">
<label for="ip" class="form-label text-primary">Adres IPv4</label>
<input id="ip" name="ip" class="form-control bg-dark text-white border-secondary" inputmode="numeric"
autocomplete="off" required placeholder="np. 185.12.34.56" pattern="\\d{1,3}(\\.\\d{1,3}){3}">
<div class="form-text text-primary"><code>Format: xxx.xxx.xxx.xxx</code></div>
<div class="invalid-feedback">Podaj poprawny adres IPv4.</div>
</div>
<div class="col-12">
<button type="button" class="btn btn-primary-outline" data-bs-toggle="modal" data-bs-target="#confirmResetIpModal">
Resetuj liczniki dla IP
</button>
</div>
</form>
</div>
</div>
<!-- Reset globalny -->
<div class="card bg-dark border-danger mb-4">
<div class="card-header">Reset wszystkich liczników błędów</div>
<div class="card-body">
<form method="post" id="reset-all-form">
<input type="hidden" name="reset_all_errors">
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#confirmResetAllModal">
Resetuj wszystkie liczniki błędów
</button>
</form>
</div>
</div>
<!-- Modale potwierdzeń -->
<div class="modal fade" id="confirmResetIpModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">Potwierdź reset dla IP</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
Czy na pewno zresetować liczniki błędów dla adresu <span class="fw-bold" id="confirm-ip-value"></span>?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Anuluj</button>
<button type="submit" form="reset-ip-form" class="btn btn-outline-warning" id="btn-confirm-ip">Tak, resetuj</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="confirmResetAllModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">Potwierdź reset globalny</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
Ta operacja wyzeruje <span class="fw-bold">wszystkie</span> liczniki błędów. Kontynuować?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Anuluj</button>
<button type="submit" form="reset-all-form" class="btn btn-outline-danger" id="btn-confirm-all">Tak, resetuj
wszystko</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/ip_validate.js') }}" defer></script>
{% if message %}
<script>window.addEventListener('DOMContentLoaded', () => window.showToast?.({ text: {{ message| tojson }}, variant: 'success' }));</script>
{% endif %}
{% if error %}
<script>window.addEventListener('DOMContentLoaded', () => window.showToast?.({ text: {{ error| tojson }}, variant: 'danger' }));</script>
{% endif %}
{% endblock %}

97
templates/stats.html Normal file
View File

@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Statystyki{% endblock %}
{% block content %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">Rozkład geograficzny</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark align-middle mb-0" id="geo-table">
<thead>
<tr>
<th style="width:48%">Kraj</th>
<th style="width:40%">Udział</th>
<th class="text-end" style="width:12%">Liczba</th>
</tr>
</thead>
<tbody>
{% set ns = namespace(total_geo=0) %}
{% for _, c in stats.geo_distribution.items() %}{% set ns.total_geo = ns.total_geo + (c|int) %}{%
endfor %}
{% for country, count in stats.geo_distribution.items() %}
{% set pct = ((count|int) / (ns.total_geo if ns.total_geo>0 else 1) * 100) | round(1) %}
{% set pct = 0 if pct < 0 else (100 if pct> 100 else pct) %}
<tr>
<td><span class="d-inline-block text-truncate" style="max-width:95%"
title="{{ country }}">{{ country }}</span></td>
<td>
<div class="progress bg-black border border-secondary" style="height:10px;">
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ pct }}%;"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="text-secondary small mt-1">{{ pct }}%</div>
</td>
<td class="text-end"><span class="badge bg-secondary">{{ count|int }}</span></td>
</tr>
{% endfor %}
{% if stats.geo_distribution|length == 0 %}
<tr>
<td colspan="3" class="text-center text-secondary">Brak danych</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Reasons -->
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">Przyczyny banów</div>
<div class="card-body">
{% set nsr = namespace(total=0) %}
{% for _, v in stats.ban_reasons.items() %}{% set nsr.total = nsr.total + (v|int) %}{% endfor %}
{% if stats.ban_reasons|length == 0 %}
<div class="text-center text-secondary small">Brak danych</div>
{% else %}
<div class="list-group list-group-flush">
{% for reason, count in (stats.ban_reasons|dictsort(by='value', reverse=true)) %}
{% set label = reason if reason else 'Manual ban' %}
{% set pct = ((count|int) / (nsr.total if nsr.total>0 else 1) * 100) | round(1) %}
{% set pct = 0 if pct < 0 else (100 if pct> 100 else pct) %}
<div class="list-group-item bg-dark text-white border-secondary">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-truncate" title="{{ label }}">{{ label }}</span>
<span class="small text-secondary">{{ count|int }} ({{ pct }}%)</span>
</div>
<div class="progress bg-black border border-secondary" style="height:10px;">
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ pct }}%;"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6"></script>
<script>
// dane dla wykresu z Jinja -> JS
const reasonsData = (() => {
const entries = Object.entries({{ stats.ban_reasons | tojson }});
// posortuj malejąco
entries.sort((a, b) => (b[1] || 0) - (a[1] || 0));
const labels = entries.map(([k]) => k && k.length ? k : "Manual ban");
const data = entries.map(([, v]) => v);
return { labels, data };
}) ();
</script>
<script src="{{ url_for('static', filename='js/stats.js') }}" defer></script>
{% endblock %}