release
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
venv
|
||||
deny_auto.conf
|
||||
logs/*
|
||||
BIN
GeoIP/GeoLite2-City.mmdb
Normal file
BIN
GeoIP/GeoLite2-City.mmdb
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 MiB |
14
autoban.service
Normal file
14
autoban.service
Normal 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
432
config.py
Normal 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
17
nginx-autoban.service
Normal 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
12
nginx-vhost.conf
Normal 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
15
requirements.txt
Normal 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
8
run.py
Normal 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
71
static/css/base.css
Normal 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
146
static/js/bans.js
Normal 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
279
static/js/charts.js
Normal 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
59
static/js/check.js
Normal 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
93
static/js/dashboard.js
Normal 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
39
static/js/delete_bans.js
Normal 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
64
static/js/ip_validate.js
Normal 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
286
static/js/logs.js
Normal 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();
|
||||
});
|
||||
})();
|
||||
16
static/js/set_log_level.js
Normal file
16
static/js/set_log_level.js
Normal 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
72
static/js/stats.js
Normal 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
35
static/js/toast.js
Normal 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
82
templates/base.html
Normal 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
182
templates/charts.html
Normal 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
190
templates/check.html
Normal 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
161
templates/index.html
Normal 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
205
templates/list.html
Normal 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
107
templates/logs.html
Normal 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
99
templates/reset.html
Normal 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
97
templates/stats.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user