Files
JTL-WAFI/jtl-wafi-dashboard.py
2026-01-08 14:21:31 +01:00

1830 lines
146 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
JTL-WAFi Dashboard v3.1.0 - WebSocket Real-Time Dashboard
Features:
- Bot Rate-Limiting: Bots nach Rate-Limit bannen
- Country Rate-Limiting: Länder nach Rate-Limit bannen
- Unlimitierte Länder: Definierte Länder werden nicht limitiert
- Monitor-Only Modus: Nur überwachen ohne zu blocken
- Auto-Update: Dashboard und Agents über Git aktualisieren
- PHP-FPM Restart: OPcache automatisch leeren
- Alle Agent/Shop-Daten im Memory
- DB nur für: Passwort, Tokens, Sessions
- IP Ban/Whitelist: IPs manuell bannen oder whitelisten
- Live Stats: Top IPs, Top Requests, Suspicious IPs
- Auto-Ban: Automatische IP-Bans bei verdächtigem Verhalten
v3.1.0: IP Ban/Whitelist, Enhanced Live Logs, Human/Bot Stats Split
"""
import os
import sys
import json
import secrets
import hashlib
import asyncio
import subprocess
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List, Set
from dataclasses import dataclass, field
from collections import deque
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware
import uvicorn
# =============================================================================
# VERSION & CONFIG
# =============================================================================
VERSION = "3.1.0"
DATA_DIR = "/var/lib/jtl-wafi"
SSL_DIR = "/var/lib/jtl-wafi/ssl"
SSL_CERT = "/var/lib/jtl-wafi/ssl/server.crt"
SSL_KEY = "/var/lib/jtl-wafi/ssl/server.key"
CONFIG_FILE = "/var/lib/jtl-wafi/config.json"
TOKENS_FILE = "/var/lib/jtl-wafi/tokens.json"
AGENT_TIMEOUT = 120
HISTORY_MAX_POINTS = 1000 # Max Datenpunkte pro Shop
SECRET_KEY = os.environ.get("DASHBOARD_SECRET", secrets.token_hex(32))
# =============================================================================
# AUTO-UPDATE
# =============================================================================
DASHBOARD_UPDATE_URL = "https://git.jtl-hosting.de/thomasciesla/JTL-WAFI/raw/branch/main/jtl-wafi-dashboard.py"
AGENT_UPDATE_URL = "https://git.jtl-hosting.de/thomasciesla/JTL-WAFI/raw/branch/main/jtl-wafi-agent.py"
# =============================================================================
# UTILITY
# =============================================================================
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def utc_now_str() -> str:
return utc_now().strftime("%Y-%m-%d %H:%M:%S")
def utc_now_iso() -> str:
return utc_now().strftime('%Y-%m-%dT%H:%M:%SZ')
# =============================================================================
# IN-MEMORY DATA STORE
# =============================================================================
@dataclass
class AgentData:
id: str
hostname: str
version: str = ""
os_info: Dict = field(default_factory=dict)
first_seen: str = ""
last_seen: str = ""
approved: bool = False
token: str = ""
status: str = "pending"
load_1m: float = 0.0
load_5m: float = 0.0
memory_percent: float = 0.0
uptime_seconds: int = 0
shops_total: int = 0
shops_active: int = 0
@dataclass
class ShopData:
"""Shop-Daten v2.5 mit Country-Rate-Limiting Support."""
domain: str
agent_id: str
agent_hostname: str = ""
status: str = "inactive"
link11: bool = False
link11_ip: str = ""
activated: str = ""
runtime_minutes: float = 0.0
# v2.5: Neue Konfiguration
bot_mode: bool = False
bot_rate_limit: int = 0
bot_ban_duration: int = 0
country_mode: bool = False
country_rate_limit: int = 0
country_ban_duration: int = 0
unlimited_countries: List[str] = field(default_factory=list)
monitor_only: bool = False
# Stats (v2.5 erweitert)
log_entries: int = 0
bot_bans: int = 0
country_bans: int = 0
active_bot_bans: int = 0
active_country_bans: int = 0
banned_bots: List[str] = field(default_factory=list)
banned_countries: List[str] = field(default_factory=list)
req_per_min: float = 0.0
unique_ips: int = 0
unique_bots: int = 0
unique_countries: int = 0
top_bots: Dict[str, int] = field(default_factory=dict)
top_ips: Dict[str, int] = field(default_factory=dict)
top_countries: Dict[str, int] = field(default_factory=dict)
human_requests: int = 0
bot_requests: int = 0
human_rpm: float = 0.0
bot_rpm: float = 0.0
# History für Graph
history: deque = field(default_factory=lambda: deque(maxlen=HISTORY_MAX_POINTS))
bot_history: Dict[str, deque] = field(default_factory=dict)
country_history: Dict[str, deque] = field(default_factory=dict)
class DataStore:
"""In-Memory Datenspeicher - Thread-safe durch asyncio."""
def __init__(self):
self.agents: Dict[str, AgentData] = {}
self.shops: Dict[str, ShopData] = {}
self.sessions: Dict[str, Dict] = {} # token -> {username, expires}
self._password_hash: Optional[str] = None
self._tokens: Dict[str, str] = {} # agent_id -> token
self._load_persistent_data()
def _load_persistent_data(self):
"""Lädt persistente Daten (Passwort, Tokens)."""
os.makedirs(DATA_DIR, exist_ok=True)
# Config laden (Passwort)
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
self._password_hash = config.get('password_hash')
except:
pass
# Tokens laden
if os.path.exists(TOKENS_FILE):
try:
with open(TOKENS_FILE, 'r') as f:
self._tokens = json.load(f)
except:
pass
def _save_config(self):
"""Speichert Config."""
with open(CONFIG_FILE, 'w') as f:
json.dump({'password_hash': self._password_hash}, f)
def _save_tokens(self):
"""Speichert Tokens."""
with open(TOKENS_FILE, 'w') as f:
json.dump(self._tokens, f)
# === Password ===
def get_password_hash(self) -> Optional[str]:
return self._password_hash
def set_password(self, password: str):
self._password_hash = hashlib.sha256(password.encode()).hexdigest()
self._save_config()
def verify_password(self, password: str) -> bool:
if not self._password_hash:
return False
return hashlib.sha256(password.encode()).hexdigest() == self._password_hash
# === Sessions ===
def create_session(self, username: str) -> str:
token = secrets.token_hex(32)
expires = (utc_now() + timedelta(hours=24)).isoformat()
self.sessions[token] = {'username': username, 'expires': expires}
return token
def verify_session(self, token: str) -> Optional[str]:
if not token or token not in self.sessions:
return None
session = self.sessions[token]
expires = datetime.fromisoformat(session['expires'])
if utc_now() > expires:
del self.sessions[token]
return None
return session['username']
def delete_session(self, token: str):
self.sessions.pop(token, None)
# === Agent Tokens ===
def get_agent_token(self, agent_id: str) -> Optional[str]:
return self._tokens.get(agent_id)
def set_agent_token(self, agent_id: str, token: str):
self._tokens[agent_id] = token
self._save_tokens()
# === Agents ===
def get_or_create_agent(self, agent_id: str, hostname: str) -> AgentData:
if agent_id not in self.agents:
self.agents[agent_id] = AgentData(
id=agent_id,
hostname=hostname,
first_seen=utc_now_str()
)
# Prüfe ob Token existiert
if agent_id in self._tokens:
self.agents[agent_id].approved = True
self.agents[agent_id].token = self._tokens[agent_id]
self.agents[agent_id].status = 'online'
return self.agents[agent_id]
def get_agent(self, agent_id: str) -> Optional[AgentData]:
return self.agents.get(agent_id)
def get_all_agents(self) -> List[Dict]:
result = []
for agent in self.agents.values():
# Status prüfen
status = agent.status
if status == 'online' and agent.last_seen:
try:
last = datetime.strptime(agent.last_seen, "%Y-%m-%d %H:%M:%S")
if (utc_now().replace(tzinfo=None) - last).total_seconds() > AGENT_TIMEOUT:
status = 'offline'
except:
pass
result.append({
'id': agent.id,
'hostname': agent.hostname,
'version': agent.version,
'status': status,
'approved': agent.approved,
'first_seen': agent.first_seen,
'last_seen': agent.last_seen,
'load_1m': agent.load_1m,
'memory_percent': agent.memory_percent,
'shops_total': agent.shops_total,
'shops_active': agent.shops_active
})
return result
# === Shops ===
def update_shop(self, agent_id: str, agent_hostname: str, shop_data: Dict) -> ShopData:
"""Aktualisiert Shop-Daten vom Agent (v2.5)."""
domain = shop_data.get('domain')
if domain not in self.shops:
self.shops[domain] = ShopData(domain=domain, agent_id=agent_id)
shop = self.shops[domain]
shop.agent_id = agent_id
shop.agent_hostname = agent_hostname
shop.status = shop_data.get('status', 'inactive')
shop.link11 = bool(shop_data.get('link11'))
shop.link11_ip = shop_data.get('link11_ip', '')
shop.activated = shop_data.get('activated', '')
shop.runtime_minutes = shop_data.get('runtime_minutes', 0)
# v2.5 Konfiguration
shop.bot_mode = shop_data.get('bot_mode', False)
shop.bot_rate_limit = shop_data.get('bot_rate_limit') or 0
shop.bot_ban_duration = shop_data.get('bot_ban_duration') or 0
shop.country_mode = shop_data.get('country_mode', False)
shop.country_rate_limit = shop_data.get('country_rate_limit') or 0
shop.country_ban_duration = shop_data.get('country_ban_duration') or 0
shop.unlimited_countries = shop_data.get('unlimited_countries', [])
shop.monitor_only = shop_data.get('monitor_only', False)
# Stats (v2.5)
stats = shop_data.get('stats', {})
if stats:
shop.log_entries = stats.get('log_entries', 0)
shop.bot_bans = stats.get('bot_bans', 0)
shop.country_bans = stats.get('country_bans', 0)
shop.active_bot_bans = stats.get('active_bot_bans', 0)
shop.active_country_bans = stats.get('active_country_bans', 0)
shop.banned_bots = stats.get('banned_bots', [])
shop.banned_countries = stats.get('banned_countries', [])
shop.req_per_min = stats.get('req_per_min', 0)
shop.unique_ips = stats.get('unique_ips', 0)
shop.unique_bots = stats.get('unique_bots', 0)
shop.unique_countries = stats.get('unique_countries', 0)
shop.top_bots = stats.get('top_bots', {})
shop.top_ips = stats.get('top_ips', {})
shop.top_countries = stats.get('top_countries', {})
shop.human_requests = stats.get('human_requests', 0)
shop.bot_requests = stats.get('bot_requests', 0)
shop.human_rpm = stats.get('human_rpm', 0.0)
shop.bot_rpm = stats.get('bot_rpm', 0.0)
# History für Graph
shop.history.append({
'timestamp': utc_now_str(),
'req_per_min': shop.req_per_min,
'active_bot_bans': shop.active_bot_bans,
'active_country_bans': shop.active_country_bans,
'human_requests': shop.human_requests,
'bot_requests': shop.bot_requests,
'human_rpm': shop.human_rpm,
'bot_rpm': shop.bot_rpm
})
return shop
def update_shop_stats(self, domain: str, stats: Dict):
"""Aktualisiert Shop-Statistiken (v2.5)."""
if domain not in self.shops:
return
shop = self.shops[domain]
shop.log_entries = stats.get('log_entries', shop.log_entries)
shop.bot_bans = stats.get('bot_bans', shop.bot_bans)
shop.country_bans = stats.get('country_bans', shop.country_bans)
shop.active_bot_bans = stats.get('active_bot_bans', shop.active_bot_bans)
shop.active_country_bans = stats.get('active_country_bans', shop.active_country_bans)
shop.banned_bots = stats.get('banned_bots', shop.banned_bots)
shop.banned_countries = stats.get('banned_countries', shop.banned_countries)
shop.req_per_min = stats.get('req_per_min', shop.req_per_min)
shop.unique_ips = stats.get('unique_ips', shop.unique_ips)
shop.unique_bots = stats.get('unique_bots', shop.unique_bots)
shop.unique_countries = stats.get('unique_countries', shop.unique_countries)
shop.top_bots = stats.get('top_bots', shop.top_bots)
shop.top_ips = stats.get('top_ips', shop.top_ips)
shop.top_countries = stats.get('top_countries', shop.top_countries)
shop.human_requests = stats.get('human_requests', shop.human_requests)
shop.bot_requests = stats.get('bot_requests', shop.bot_requests)
shop.human_rpm = stats.get('human_rpm', shop.human_rpm)
shop.bot_rpm = stats.get('bot_rpm', shop.bot_rpm)
timestamp = utc_now_str()
# Gesamt-History
shop.history.append({
'timestamp': timestamp,
'req_per_min': shop.req_per_min,
'active_bot_bans': shop.active_bot_bans,
'active_country_bans': shop.active_country_bans,
'human_requests': shop.human_requests,
'bot_requests': shop.bot_requests,
'human_rpm': shop.human_rpm,
'bot_rpm': shop.bot_rpm
})
# Bot-History aktualisieren
top_bots = stats.get('top_bots', {})
for bot_name, count in top_bots.items():
if bot_name not in shop.bot_history:
shop.bot_history[bot_name] = deque(maxlen=HISTORY_MAX_POINTS)
shop.bot_history[bot_name].append({
'timestamp': timestamp,
'count': count
})
# Country-History aktualisieren
top_countries = stats.get('top_countries', {})
for country, count in top_countries.items():
if country not in shop.country_history:
shop.country_history[country] = deque(maxlen=HISTORY_MAX_POINTS)
shop.country_history[country].append({
'timestamp': timestamp,
'count': count
})
def get_shop(self, domain: str) -> Optional[ShopData]:
return self.shops.get(domain)
def get_all_shops(self) -> List[Dict]:
"""Gibt alle Shops mit v2.5 Struktur zurück."""
result = []
for shop in self.shops.values():
result.append({
'domain': shop.domain,
'agent_id': shop.agent_id,
'agent_hostname': shop.agent_hostname,
'status': shop.status,
'link11': shop.link11,
'link11_ip': shop.link11_ip,
'activated': shop.activated,
'runtime_minutes': shop.runtime_minutes,
# v2.5 Konfiguration
'bot_mode': shop.bot_mode,
'bot_rate_limit': shop.bot_rate_limit,
'bot_ban_duration': shop.bot_ban_duration,
'country_mode': shop.country_mode,
'country_rate_limit': shop.country_rate_limit,
'country_ban_duration': shop.country_ban_duration,
'unlimited_countries': shop.unlimited_countries,
'monitor_only': shop.monitor_only,
# Stats
'stats': {
'log_entries': shop.log_entries,
'bot_bans': shop.bot_bans,
'country_bans': shop.country_bans,
'active_bot_bans': shop.active_bot_bans,
'active_country_bans': shop.active_country_bans,
'banned_bots': shop.banned_bots,
'banned_countries': shop.banned_countries,
'req_per_min': shop.req_per_min,
'unique_ips': shop.unique_ips,
'unique_bots': shop.unique_bots,
'unique_countries': shop.unique_countries,
'top_bots': shop.top_bots,
'top_ips': shop.top_ips,
'top_countries': shop.top_countries,
'human_requests': shop.human_requests,
'bot_requests': shop.bot_requests,
'human_rpm': shop.human_rpm,
'bot_rpm': shop.bot_rpm
}
})
return result
def get_shop_history(self, domain: str) -> Dict:
"""Gibt Shop-History inkl. Country-History zurück."""
shop = self.shops.get(domain)
if not shop:
return {'history': [], 'bot_history': {}, 'country_history': {}}
# Bot-History in JSON-serialisierbares Format
bot_history = {}
for bot_name, history in shop.bot_history.items():
bot_history[bot_name] = list(history)
# Country-History in JSON-serialisierbares Format
country_history = {}
for country, history in shop.country_history.items():
country_history[country] = list(history)
return {
'history': list(shop.history),
'bot_history': bot_history,
'country_history': country_history
}
def get_top_shops(self, limit: int = 10, sort_by: str = 'req_per_min') -> List[Dict]:
"""Gibt Top Shops sortiert nach verschiedenen Kriterien zurück."""
shops_list = []
for shop in self.shops.values():
shops_list.append({
'domain': shop.domain,
'agent_hostname': shop.agent_hostname,
'status': shop.status,
'req_per_min': shop.req_per_min,
'active_bot_bans': shop.active_bot_bans,
'active_country_bans': shop.active_country_bans,
'link11': shop.link11,
'bot_mode': shop.bot_mode,
'country_mode': shop.country_mode,
'monitor_only': shop.monitor_only
})
# Sortieren
if sort_by == 'active_bans':
shops_list.sort(key=lambda x: x['active_bot_bans'] + x['active_country_bans'], reverse=True)
else:
shops_list.sort(key=lambda x: x['req_per_min'], reverse=True)
if limit:
return shops_list[:limit]
return shops_list
def get_stats(self) -> Dict:
agents_online = sum(1 for a in self.agents.values()
if a.approved and a.status == 'online')
agents_pending = sum(1 for a in self.agents.values() if not a.approved)
shops_active = sum(1 for s in self.shops.values() if s.status == 'active')
shops_total = len(self.shops)
shops_link11 = sum(1 for s in self.shops.values() if s.link11)
shops_direct = shops_total - shops_link11
req_per_min = sum(s.req_per_min for s in self.shops.values())
human_rpm = sum(s.human_rpm for s in self.shops.values())
bot_rpm = sum(s.bot_rpm for s in self.shops.values())
active_bot_bans = sum(s.active_bot_bans for s in self.shops.values())
active_country_bans = sum(s.active_country_bans for s in self.shops.values())
return {
'agents_online': agents_online,
'agents_pending': agents_pending,
'shops_active': shops_active,
'shops_total': shops_total,
'shops_link11': shops_link11,
'shops_direct': shops_direct,
'req_per_min': round(req_per_min, 1),
'human_rpm': round(human_rpm, 1),
'bot_rpm': round(bot_rpm, 1),
'active_bot_bans': active_bot_bans,
'active_country_bans': active_country_bans,
'active_bans': active_bot_bans + active_country_bans
}
# Global Data Store
store = DataStore()
# =============================================================================
# SSL
# =============================================================================
def generate_ssl_certificate():
os.makedirs(SSL_DIR, exist_ok=True)
if os.path.exists(SSL_CERT) and os.path.exists(SSL_KEY):
return
print("🔐 Generiere SSL-Zertifikat...")
try:
subprocess.run([
'openssl', 'req', '-x509', '-nodes',
'-days', '3650',
'-newkey', 'rsa:2048',
'-keyout', SSL_KEY,
'-out', SSL_CERT,
'-subj', '/CN=jtl-wafi/O=JTL-WAFi/C=DE'
], check=True, capture_output=True)
os.chmod(SSL_KEY, 0o600)
os.chmod(SSL_CERT, 0o644)
print(f"✅ SSL-Zertifikat generiert: {SSL_CERT}")
except Exception as e:
print(f"❌ SSL Fehler: {e}")
raise
# =============================================================================
# CONNECTION MANAGER
# =============================================================================
class ConnectionManager:
def __init__(self):
self.agent_connections: Dict[str, WebSocket] = {}
self.browser_connections: Set[WebSocket] = set()
self.agent_hostnames: Dict[str, str] = {}
async def connect_agent(self, agent_id: str, hostname: str, websocket: WebSocket):
# Alte Verbindung schließen
if agent_id in self.agent_connections:
try:
await self.agent_connections[agent_id].close()
except:
pass
self.agent_connections[agent_id] = websocket
self.agent_hostnames[agent_id] = hostname
print(f"✅ Agent verbunden: {hostname}")
async def disconnect_agent(self, agent_id: str):
self.agent_connections.pop(agent_id, None)
hostname = self.agent_hostnames.pop(agent_id, "unknown")
# Status updaten
agent = store.get_agent(agent_id)
if agent:
agent.status = 'offline'
agent.last_seen = utc_now_str()
print(f"❌ Agent getrennt: {hostname}")
await self.broadcast_to_browsers({
'type': 'agent.offline',
'data': {'agent_id': agent_id, 'hostname': hostname}
})
async def connect_browser(self, websocket: WebSocket):
self.browser_connections.add(websocket)
print(f"🌐 Browser verbunden (Total: {len(self.browser_connections)})")
async def disconnect_browser(self, websocket: WebSocket):
self.browser_connections.discard(websocket)
print(f"🌐 Browser getrennt (Total: {len(self.browser_connections)})")
async def send_to_agent(self, agent_id: str, message: Dict):
ws = self.agent_connections.get(agent_id)
if ws:
try:
await ws.send_json(message)
except Exception as e:
print(f"Send to agent error: {e}")
async def broadcast_to_browsers(self, message: Dict):
dead = set()
for ws in self.browser_connections:
try:
await ws.send_json(message)
except:
dead.add(ws)
self.browser_connections -= dead
def get_agent_for_shop(self, domain: str) -> Optional[str]:
shop = store.get_shop(domain)
return shop.agent_id if shop else None
def is_agent_connected(self, agent_id: str) -> bool:
return agent_id in self.agent_connections
manager = ConnectionManager()
# =============================================================================
# FASTAPI APP
# =============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
generate_ssl_certificate()
yield
app = FastAPI(title="JTL-WAFi Dashboard", version=VERSION, lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="jtl_wafi_session", max_age=86400)
# =============================================================================
# AUTH HELPERS
# =============================================================================
async def get_current_user(request: Request) -> Optional[str]:
token = request.session.get("token")
return store.verify_session(token)
# =============================================================================
# WEBSOCKET: AGENT
# =============================================================================
@app.websocket("/ws/agent")
async def agent_websocket(websocket: WebSocket):
await websocket.accept()
agent_id = None
try:
async for message in websocket.iter_text():
try:
data = json.loads(message)
event_type = data.get('type')
event_data = data.get('data', {})
if event_type == 'agent.connect':
agent_id = event_data.get('agent_id')
hostname = event_data.get('hostname')
token = event_data.get('token')
version = event_data.get('version', '')
os_info = event_data.get('os_info', {})
shops_summary = event_data.get('shops_summary', {})
# Agent registrieren
agent = store.get_or_create_agent(agent_id, hostname)
agent.hostname = hostname
agent.version = version
agent.os_info = os_info
agent.last_seen = utc_now_str()
agent.shops_total = shops_summary.get('total', 0)
agent.shops_active = shops_summary.get('active', 0)
# Token prüfen
stored_token = store.get_agent_token(agent_id)
if stored_token and token == stored_token:
agent.approved = True
agent.token = stored_token
agent.status = 'online'
await manager.connect_agent(agent_id, hostname, websocket)
# Browser informieren
await manager.broadcast_to_browsers({
'type': 'agent.online' if agent.approved else 'agent.pending',
'data': {
'agent_id': agent_id,
'hostname': hostname,
'version': version,
'status': agent.status,
'approved': agent.approved,
'shops_total': shops_summary.get('total', 0),
'shops_active': shops_summary.get('active', 0)
}
})
# Token senden wenn approved
if agent.approved:
await websocket.send_json({
'type': 'auth.approved',
'data': {'token': agent.token}
})
elif event_type == 'agent.heartbeat':
if agent_id:
agent = store.get_agent(agent_id)
if agent:
system = event_data.get('system', {})
shops_summary = event_data.get('shops_summary', {})
agent.last_seen = utc_now_str()
agent.load_1m = system.get('load_1m', 0)
agent.load_5m = system.get('load_5m', 0)
agent.memory_percent = system.get('memory_percent', 0)
agent.uptime_seconds = system.get('uptime_seconds', 0)
agent.shops_total = shops_summary.get('total', 0)
agent.shops_active = shops_summary.get('active', 0)
await manager.broadcast_to_browsers({
'type': 'agent.update',
'data': {
'agent_id': agent_id,
'hostname': agent.hostname,
'system': system,
'shops_summary': shops_summary
}
})
elif event_type == 'shop.full_update':
if agent_id:
agent = store.get_agent(agent_id)
hostname = agent.hostname if agent else ''
shops = event_data.get('shops', [])
for shop_data in shops:
store.update_shop(agent_id, hostname, shop_data)
await manager.broadcast_to_browsers({
'type': 'shop.full_update',
'data': {
'agent_id': agent_id,
'hostname': hostname,
'shops': shops
}
})
elif event_type == 'shop.stats':
if agent_id:
domain = event_data.get('domain')
stats = event_data.get('stats', {})
store.update_shop_stats(domain, stats)
await manager.broadcast_to_browsers({
'type': 'shop.stats',
'data': {'domain': domain, 'stats': stats}
})
elif event_type == 'log.entry':
await manager.broadcast_to_browsers({
'type': 'log.entry',
'data': event_data
})
elif event_type == 'bot.banned':
await manager.broadcast_to_browsers({
'type': 'bot.banned',
'data': event_data
})
elif event_type == 'command.result':
await manager.broadcast_to_browsers({
'type': 'command.result',
'data': event_data
})
elif event_type == 'livestats.result':
await manager.broadcast_to_browsers({
'type': 'livestats.result',
'data': event_data
})
except json.JSONDecodeError:
pass
except Exception as e:
print(f"Agent message error: {e}")
except WebSocketDisconnect:
pass
except Exception as e:
print(f"Agent WebSocket error: {e}")
finally:
if agent_id:
await manager.disconnect_agent(agent_id)
# =============================================================================
# WEBSOCKET: BROWSER
# =============================================================================
@app.websocket("/ws/dashboard")
async def dashboard_websocket(websocket: WebSocket):
await websocket.accept()
await manager.connect_browser(websocket)
try:
# Initial state senden
await websocket.send_json({
'type': 'initial_state',
'data': {
'agents': store.get_all_agents(),
'shops': store.get_all_shops(),
'stats': store.get_stats()
}
})
async for message in websocket.iter_text():
try:
data = json.loads(message)
event_type = data.get('type')
event_data = data.get('data', {})
if event_type == 'log.subscribe':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'log.subscribe',
'data': {'shop': domain}
})
elif event_type == 'log.unsubscribe':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'log.unsubscribe',
'data': {'shop': domain}
})
elif event_type == 'command.livestats':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'command.livestats',
'data': event_data
})
elif event_type == 'command.ban':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'command.ban',
'data': event_data
})
elif event_type == 'command.unban':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'command.unban',
'data': event_data
})
elif event_type == 'command.whitelist':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'command.whitelist',
'data': event_data
})
elif event_type == 'command.unwhitelist':
domain = event_data.get('shop')
agent_id = manager.get_agent_for_shop(domain)
if agent_id:
await manager.send_to_agent(agent_id, {
'type': 'command.unwhitelist',
'data': event_data
})
elif event_type == 'get_shop_history':
domain = event_data.get('domain')
data = store.get_shop_history(domain)
await websocket.send_json({
'type': 'shop_history',
'data': {'domain': domain, **data}
})
elif event_type == 'get_top_shops':
sort_by = event_data.get('sort_by', 'req_per_min')
limit = event_data.get('limit', 10)
shops = store.get_top_shops(limit=limit, sort_by=sort_by)
await websocket.send_json({
'type': 'top_shops',
'data': {'shops': shops, 'sort_by': sort_by}
})
elif event_type == 'get_all_shops_sorted':
sort_by = event_data.get('sort_by', 'req_per_min')
shops = store.get_top_shops(limit=None, sort_by=sort_by)
await websocket.send_json({
'type': 'all_shops_sorted',
'data': {'shops': shops, 'sort_by': sort_by}
})
elif event_type == 'refresh':
await websocket.send_json({
'type': 'refresh',
'data': {
'agents': store.get_all_agents(),
'shops': store.get_all_shops(),
'stats': store.get_stats()
}
})
except Exception as e:
print(f"Browser message error: {e}")
except WebSocketDisconnect:
pass
except Exception as e:
print(f"Browser WebSocket error: {e}")
finally:
await manager.disconnect_browser(websocket)
# =============================================================================
# HTTP ENDPOINTS
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
user = await get_current_user(request)
if not store.get_password_hash():
return get_setup_html()
if not user:
return get_login_html()
return get_dashboard_html()
@app.post("/setup")
async def setup(request: Request, password: str = Form(...), confirm: str = Form(...)):
if store.get_password_hash():
raise HTTPException(400, "Passwort bereits gesetzt")
if password != confirm:
return HTMLResponse(get_setup_html("Passwörter stimmen nicht überein"))
if len(password) < 8:
return HTMLResponse(get_setup_html("Passwort muss mindestens 8 Zeichen haben"))
store.set_password(password)
token = store.create_session("admin")
response = RedirectResponse(url="/", status_code=303)
request.session["token"] = token
return response
@app.post("/login")
async def login(request: Request, password: str = Form(...)):
if not store.verify_password(password):
return HTMLResponse(get_login_html("Falsches Passwort"))
token = store.create_session("admin")
response = RedirectResponse(url="/", status_code=303)
request.session["token"] = token
return response
@app.get("/logout")
async def logout(request: Request):
token = request.session.get("token")
if token:
store.delete_session(token)
request.session.clear()
return RedirectResponse(url="/")
@app.post("/api/agents/{agent_id}/approve")
async def approve_agent(agent_id: str, request: Request):
user = await get_current_user(request)
if not user:
raise HTTPException(401)
agent = store.get_agent(agent_id)
if not agent:
raise HTTPException(404, "Agent nicht gefunden")
# Token generieren
token = secrets.token_hex(32)
agent.approved = True
agent.token = token
agent.status = 'online'
store.set_agent_token(agent_id, token)
# Token an Agent senden
if manager.is_agent_connected(agent_id):
await manager.send_to_agent(agent_id, {
'type': 'auth.approved',
'data': {'token': token}
})
await manager.broadcast_to_browsers({
'type': 'agent.approved',
'data': {'agent_id': agent_id}
})
return {"success": True}
@app.post("/api/shops/activate")
async def activate_shop(
request: Request,
domain: str = Form(...),
bot_mode: str = Form("false"),
bot_rate_limit: int = Form(30),
bot_ban_duration: int = Form(300),
country_mode: str = Form("false"),
country_rate_limit: int = Form(100),
country_ban_duration: int = Form(600),
unlimited_countries: str = Form(""),
monitor_only: str = Form("false"),
restart_fpm: str = Form("false")
):
"""Aktiviert Blocking für einen Shop (v2.5)."""
user = await get_current_user(request)
if not user:
raise HTTPException(401)
# String zu Boolean konvertieren
is_bot_mode = bot_mode.lower() in ('true', '1', 'yes', 'on')
is_country_mode = country_mode.lower() in ('true', '1', 'yes', 'on')
is_monitor_only = monitor_only.lower() in ('true', '1', 'yes', 'on')
is_restart_fpm = restart_fpm.lower() in ('true', '1', 'yes', 'on')
# unlimited_countries als Liste parsen
countries_list = []
if unlimited_countries:
countries_list = [c.strip().lower() for c in unlimited_countries.split(',') if c.strip()]
agent_id = manager.get_agent_for_shop(domain)
if not agent_id or not manager.is_agent_connected(agent_id):
return JSONResponse({"success": False, "error": "Agent nicht verbunden"})
command_id = secrets.token_hex(8)
await manager.send_to_agent(agent_id, {
'type': 'command.activate',
'data': {
'command_id': command_id,
'shop': domain,
'bot_mode': is_bot_mode and not is_monitor_only,
'bot_rate_limit': bot_rate_limit if is_bot_mode else None,
'bot_ban_duration': bot_ban_duration if is_bot_mode else None,
'country_mode': is_country_mode and not is_monitor_only,
'country_rate_limit': country_rate_limit if is_country_mode else None,
'country_ban_duration': country_ban_duration if is_country_mode else None,
'unlimited_countries': countries_list if is_country_mode else [],
'monitor_only': is_monitor_only,
'restart_fpm': is_restart_fpm
}
})
return {"success": True, "command_id": command_id}
@app.post("/api/shops/deactivate")
async def deactivate_shop(
request: Request,
domain: str = Form(...),
restart_fpm: str = Form("false")
):
user = await get_current_user(request)
if not user:
raise HTTPException(401)
is_restart_fpm = restart_fpm.lower() in ('true', '1', 'yes', 'on')
agent_id = manager.get_agent_for_shop(domain)
if not agent_id or not manager.is_agent_connected(agent_id):
return JSONResponse({"success": False, "error": "Agent nicht verbunden"})
command_id = secrets.token_hex(8)
await manager.send_to_agent(agent_id, {
'type': 'command.deactivate',
'data': {
'command_id': command_id,
'shop': domain,
'restart_fpm': is_restart_fpm
}
})
return {"success": True, "command_id": command_id}
@app.post("/api/shops/bulk-activate")
async def bulk_activate(
request: Request,
bot_mode: str = Form("false"),
bot_rate_limit: int = Form(30),
bot_ban_duration: int = Form(300),
country_mode: str = Form("false"),
country_rate_limit: int = Form(100),
country_ban_duration: int = Form(600),
unlimited_countries: str = Form(""),
monitor_only: str = Form("false"),
filter_type: str = Form("all"),
restart_fpm: str = Form("false")
):
"""Bulk-Aktivierung für mehrere Shops (v2.5)."""
user = await get_current_user(request)
if not user:
raise HTTPException(401)
# String zu Boolean konvertieren
is_bot_mode = bot_mode.lower() in ('true', '1', 'yes', 'on')
is_country_mode = country_mode.lower() in ('true', '1', 'yes', 'on')
is_monitor_only = monitor_only.lower() in ('true', '1', 'yes', 'on')
is_restart_fpm = restart_fpm.lower() in ('true', '1', 'yes', 'on')
# unlimited_countries als Liste parsen
countries_list = []
if unlimited_countries:
countries_list = [c.strip().lower() for c in unlimited_countries.split(',') if c.strip()]
activated = 0
shops = store.get_all_shops()
for shop in shops:
if shop['status'] == 'active':
continue
if filter_type == 'direct' and shop['link11']:
continue
if filter_type == 'link11' and not shop['link11']:
continue
agent_id = shop.get('agent_id')
if not agent_id or not manager.is_agent_connected(agent_id):
continue
command_id = secrets.token_hex(8)
await manager.send_to_agent(agent_id, {
'type': 'command.activate',
'data': {
'command_id': command_id,
'shop': shop['domain'],
'bot_mode': is_bot_mode and not is_monitor_only,
'bot_rate_limit': bot_rate_limit if is_bot_mode else None,
'bot_ban_duration': bot_ban_duration if is_bot_mode else None,
'country_mode': is_country_mode and not is_monitor_only,
'country_rate_limit': country_rate_limit if is_country_mode else None,
'country_ban_duration': country_ban_duration if is_country_mode else None,
'unlimited_countries': countries_list if is_country_mode else [],
'monitor_only': is_monitor_only,
'restart_fpm': is_restart_fpm
}
})
activated += 1
# Kleine Pause um nicht zu überlasten
if activated % 5 == 0:
await asyncio.sleep(0.1)
return {"success": True, "activated": activated}
@app.post("/api/shops/bulk-deactivate")
async def bulk_deactivate(
request: Request,
filter_type: str = Form("all"),
restart_fpm: str = Form("false")
):
user = await get_current_user(request)
if not user:
raise HTTPException(401)
is_restart_fpm = restart_fpm.lower() in ('true', '1', 'yes', 'on')
deactivated = 0
shops = store.get_all_shops()
for shop in shops:
if shop['status'] != 'active':
continue
if filter_type == 'direct' and shop['link11']:
continue
if filter_type == 'link11' and not shop['link11']:
continue
agent_id = shop.get('agent_id')
if not agent_id or not manager.is_agent_connected(agent_id):
continue
command_id = secrets.token_hex(8)
await manager.send_to_agent(agent_id, {
'type': 'command.deactivate',
'data': {
'command_id': command_id,
'shop': shop['domain'],
'restart_fpm': is_restart_fpm
}
})
deactivated += 1
if deactivated % 5 == 0:
await asyncio.sleep(0.1)
return {"success": True, "deactivated": deactivated}
@app.post("/api/change-password")
async def change_password(
request: Request,
current: str = Form(...),
new_pw: str = Form(...),
confirm: str = Form(...)
):
user = await get_current_user(request)
if not user:
raise HTTPException(401)
if not store.verify_password(current):
return {"success": False, "error": "Aktuelles Passwort falsch"}
if new_pw != confirm:
return {"success": False, "error": "Neue Passwörter stimmen nicht überein"}
if len(new_pw) < 8:
return {"success": False, "error": "Mindestens 8 Zeichen"}
store.set_password(new_pw)
return {"success": True}
@app.get("/api/shop/{domain}/history")
async def get_shop_history_api(domain: str, request: Request):
user = await get_current_user(request)
if not user:
raise HTTPException(401)
data = store.get_shop_history(domain)
return {"domain": domain, **data}
@app.post("/api/update-dashboard")
async def update_dashboard(request: Request):
"""Dashboard selbst updaten."""
import urllib.request
import urllib.error
user = await get_current_user(request)
if not user:
raise HTTPException(401)
try:
# 1. Download neue Version
req = urllib.request.Request(
DASHBOARD_UPDATE_URL,
headers={'User-Agent': f'JTL-WAFi-Dashboard/{VERSION}'}
)
with urllib.request.urlopen(req, timeout=30) as response:
new_content = response.read().decode('utf-8')
# 2. Syntax-Check
compile(new_content, '<update>', 'exec')
# 3. Version extrahieren
new_version = "unknown"
for line in new_content.split('\n')[:50]:
if 'VERSION = ' in line:
new_version = line.split('=')[1].strip().strip('"\'')
break
# 4. Script-Pfad ermitteln
script_path = os.path.abspath(__file__)
backup_path = script_path + '.backup'
# 5. Backup erstellen
import shutil
shutil.copy(script_path, backup_path)
# 6. Neue Version schreiben
with open(script_path, 'w', encoding='utf-8') as f:
f.write(new_content)
# 7. Neustart planen (nach Response)
async def restart_dashboard():
await asyncio.sleep(2)
os.execv(sys.executable, [sys.executable, script_path])
asyncio.create_task(restart_dashboard())
return {
"success": True,
"message": f"Update erfolgreich ({VERSION} -> {new_version}). Dashboard startet neu...",
"old_version": VERSION,
"new_version": new_version
}
except urllib.error.URLError as e:
return {"success": False, "error": f"Download fehlgeschlagen: {str(e)}"}
except SyntaxError as e:
return {"success": False, "error": f"Syntax-Fehler in neuer Version: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Update fehlgeschlagen: {str(e)}"}
@app.post("/api/update-agents")
async def update_agents(request: Request):
"""Alle verbundenen Agents updaten."""
user = await get_current_user(request)
if not user:
raise HTTPException(401)
updated = 0
failed = 0
for agent_id in list(manager.agent_connections.keys()):
if manager.is_agent_connected(agent_id):
try:
command_id = secrets.token_hex(8)
await manager.send_to_agent(agent_id, {
'type': 'command.update',
'data': {'command_id': command_id}
})
updated += 1
except Exception:
failed += 1
return {
"success": True,
"updated": updated,
"failed": failed,
"message": f"{updated} Agent(s) werden aktualisiert..."
}
# =============================================================================
# HTML TEMPLATES
# =============================================================================
def get_setup_html(error: str = None) -> str:
error_html = f'<div class="error">{error}</div>' if error else ''
return f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JTL-WAFi Dashboard - Setup</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #e0e0e0; }}
.container {{ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 40px; width: 100%; max-width: 400px; }}
h1 {{ text-align: center; margin-bottom: 10px; color: #fff; }}
p {{ text-align: center; margin-bottom: 30px; opacity: 0.7; font-size: 14px; }}
.error {{ background: #e74c3c; color: white; padding: 10px; border-radius: 8px; margin-bottom: 20px; text-align: center; }}
label {{ display: block; margin-bottom: 5px; font-size: 14px; opacity: 0.8; }}
input {{ width: 100%; padding: 12px 16px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-size: 16px; margin-bottom: 20px; }}
input:focus {{ outline: none; border-color: #4a9eff; }}
button {{ width: 100%; padding: 14px; background: linear-gradient(135deg, #4a9eff 0%, #6c5ce7 100%); border: none; border-radius: 8px; color: white; font-size: 16px; font-weight: 600; cursor: pointer; }}
button:hover {{ filter: brightness(1.1); }}
</style>
</head>
<body>
<div class="container">
<h1>🔐 Setup</h1>
<p>Erstelle ein Admin-Passwort</p>
{error_html}
<form method="POST" action="/setup">
<label>Passwort</label>
<input type="password" name="password" required minlength="8" placeholder="Mindestens 8 Zeichen">
<label>Passwort bestätigen</label>
<input type="password" name="confirm" required>
<button type="submit">Dashboard einrichten</button>
</form>
</div>
</body>
</html>'''
def get_login_html(error: str = None) -> str:
error_html = f'<div class="error">{error}</div>' if error else ''
return f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JTL-WAFi Dashboard - Login</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #e0e0e0; }}
.container {{ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 40px; width: 100%; max-width: 400px; }}
h1 {{ text-align: center; margin-bottom: 30px; color: #fff; }}
.error {{ background: #e74c3c; color: white; padding: 10px; border-radius: 8px; margin-bottom: 20px; text-align: center; }}
input {{ width: 100%; padding: 14px 16px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-size: 16px; margin-bottom: 20px; }}
input:focus {{ outline: none; border-color: #4a9eff; }}
button {{ width: 100%; padding: 14px; background: linear-gradient(135deg, #4a9eff 0%, #6c5ce7 100%); border: none; border-radius: 8px; color: white; font-size: 16px; font-weight: 600; cursor: pointer; }}
button:hover {{ filter: brightness(1.1); }}
</style>
</head>
<body>
<div class="container">
<h1>🌍 JTL-WAFi Dashboard</h1>
{error_html}
<form method="POST" action="/login">
<input type="password" name="password" required placeholder="Passwort" autofocus>
<button type="submit">Anmelden</button>
</form>
</div>
</body>
</html>'''
def get_dashboard_html() -> str:
return '''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JTL-WAFi Dashboard v3.0</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: rgba(255,255,255,0.03);
--border: rgba(255,255,255,0.08);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--accent: #4a9eff;
--success: #00d26a;
--warning: #ffc107;
--danger: #ff4757;
--link11: #9b59b6;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; }
header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; }
.logo { font-size: 20px; font-weight: 700; }
.logo span { color: var(--accent); }
.header-right { display: flex; align-items: center; gap: 16px; }
.clock { font-size: 14px; color: var(--text-secondary); font-family: monospace; }
.connection-status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-secondary); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); }
.status-dot.connected { background: var(--success); }
.btn-header { background: transparent; border: 1px solid var(--border); color: var(--text-secondary); padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; text-decoration: none; }
.btn-header:hover { border-color: var(--accent); color: var(--accent); }
main { padding: 24px; max-width: 1800px; margin: 0 auto; }
.stats-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; }
.stat-value { font-size: 28px; font-weight: 700; }
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.danger { color: var(--danger); }
td.success { color: var(--success); }
td.warning { color: var(--warning); }
td.danger { color: var(--danger); }
.stat-value.link11 { color: var(--link11); }
.stat-value.direct { color: var(--danger); }
.top-shops-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 24px; }
.top-shops-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; cursor: pointer; }
.top-shops-header:hover .top-shops-title { color: var(--accent); }
.top-shops-title { font-size: 16px; font-weight: 600; transition: color 0.2s; }
.top-shops-subtitle { font-size: 12px; color: var(--text-secondary); }
.top-shops-list { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
.top-shop-item { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 12px; cursor: pointer; transition: border-color 0.2s; }
.top-shop-item:hover { border-color: var(--accent); }
.top-shop-domain { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; }
.top-shop-stats { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-secondary); }
.top-shop-req { color: var(--accent); font-weight: 600; }
.top-shop-bans { color: var(--warning); }
.bulk-actions { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; margin-bottom: 24px; display: flex; align-items: center; gap: 16px; }
.bulk-actions-title { font-weight: 600; color: var(--text-secondary); }
.section { margin-bottom: 32px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.section-title { font-size: 18px; font-weight: 600; }
.badge { background: var(--bg-card); border: 1px solid var(--border); padding: 4px 12px; border-radius: 20px; font-size: 12px; color: var(--text-secondary); }
.badge.link11 { border-color: var(--link11); color: var(--link11); }
.badge.direct { border-color: var(--danger); color: var(--danger); }
table { width: 100%; border-collapse: collapse; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
th, td { padding: 14px 16px; text-align: left; }
th { background: rgba(0,0,0,0.2); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); }
th.sortable { cursor: pointer; user-select: none; transition: color 0.2s; }
th.sortable:hover { color: var(--accent); }
th.sortable .sort-icon { margin-left: 4px; opacity: 0.3; }
th.sortable.asc .sort-icon, th.sortable.desc .sort-icon { opacity: 1; color: var(--accent); }
td { border-top: 1px solid var(--border); font-size: 14px; }
tr:hover td { background: rgba(255,255,255,0.02); }
.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.status-online { background: rgba(0,210,106,0.15); color: var(--success); }
.status-offline { background: rgba(255,71,87,0.15); color: var(--danger); }
.status-pending { background: rgba(255,193,7,0.15); color: var(--warning); }
.status-active { background: rgba(0,210,106,0.15); color: var(--success); }
.status-inactive { background: rgba(160,160,176,0.15); color: var(--text-secondary); }
.domain-link { color: var(--accent); cursor: pointer; text-decoration: none; }
.domain-link:hover { text-decoration: underline; }
.btn { padding: 6px 12px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.2s; }
.btn-primary { background: var(--accent); color: white; }
.btn-success { background: var(--success); color: white; }
.btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text-secondary); }
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: rgba(255,71,87,0.2); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: white; }
.btn-icon { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); cursor: pointer; text-decoration: none; }
.btn-icon:hover { border-color: var(--accent); color: var(--accent); }
.actions { display: flex; gap: 8px; align-items: center; }
.logs-panel { position: fixed; bottom: 0; left: 0; right: 0; background: var(--bg-secondary); border-top: 1px solid var(--border); height: 300px; transform: translateY(100%); transition: transform 0.3s ease; z-index: 200; }
.logs-panel.enhanced { height: 400px; }
.logs-panel.open { transform: translateY(0); }
.logs-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; height: calc(100% - 50px); padding: 12px; }
.logs-column { background: var(--card-bg); border-radius: 8px; padding: 8px; overflow-y: auto; }
.logs-column-header { font-size: 12px; font-weight: 600; color: var(--accent); margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
.logs-feed .logs-content { height: calc(100% - 30px); overflow-y: auto; }
.top-ips-list, .suspicious-ips-list, .top-requests-list, .top-blocked-list { font-size: 11px; }
.ip-item, .request-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-radius: 4px; margin-bottom: 4px; background: var(--bg); }
.ip-item:hover { background: var(--border); }
.ip-item .ip-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.ip-item .ip-addr { font-family: monospace; font-weight: 500; }
.ip-item .ip-meta { font-size: 10px; color: var(--text-secondary); }
.ip-item .ip-count { font-weight: 600; color: var(--accent); margin-right: 8px; }
.ip-item .ip-actions { display: flex; gap: 4px; }
.ip-item .ip-actions button { padding: 2px 6px; font-size: 10px; border-radius: 3px; border: none; cursor: pointer; }
.ip-item .btn-ban { background: var(--danger); color: white; }
.ip-item .btn-whitelist { background: var(--success); color: white; }
.suspicious-item { background: rgba(255,71,87,0.1); border-left: 3px solid var(--danger); }
.suspicious-item .ip-count { color: var(--danger); }
.request-item .request-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; }
.request-item .request-count { font-weight: 600; color: var(--accent); }
.logs-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--border); }
.logs-content { height: calc(100% - 48px); overflow-y: auto; padding: 12px 20px; font-family: monospace; font-size: 12px; line-height: 1.6; }
.log-entry { color: var(--text-secondary); }
.log-entry.banned { color: var(--danger); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 300; overflow-y: auto; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 16px; padding: 24px; width: 100%; max-width: 600px; max-height: 90vh; overflow-y: auto; }
.modal.large { max-width: 900px; }
.modal.xlarge { max-width: 1200px; }
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; }
.form-group input, .form-group select { width: 100%; padding: 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); font-size: 14px; }
.form-group small { display: block; margin-top: 4px; color: var(--text-secondary); font-size: 11px; }
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; }
.detail-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.detail-card-label { font-size: 11px; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 4px; }
.detail-card-value { font-size: 20px; font-weight: 600; }
.detail-section { margin-bottom: 20px; }
.detail-section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-secondary); }
.bot-list { max-height: 200px; overflow-y: auto; }
.bot-item { display: flex; justify-content: space-between; padding: 8px 12px; background: var(--bg-card); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }
.chart-container { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; height: 280px; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; font-size: 11px; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-color { width: 12px; height: 3px; border-radius: 2px; }
.toast-container { position: fixed; top: 80px; right: 24px; z-index: 400; }
.toast { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; animation: slideIn 0.3s ease; }
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--danger); }
.toast.info { border-color: var(--accent); }
.toast.warning { border-color: var(--warning); }
@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } }
.notification-badge { position: absolute; top: -4px; right: -4px; background: var(--danger); color: white; font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; border-radius: 8px; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
.notification-item { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; display: flex; gap: 10px; align-items: flex-start; }
.notification-item:last-child { border-bottom: none; }
.notification-item:hover { background: var(--bg); }
.notification-icon { font-size: 16px; flex-shrink: 0; }
.notification-content { flex: 1; min-width: 0; }
.notification-text { color: var(--text); word-break: break-word; }
.notification-time { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
@media (max-width: 1400px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } .top-shops-list { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
<body>
<header>
<div class="logo">🌍 JTL-<span>WAFi</span> <small style="font-size:11px;opacity:0.5">v3.1</small></div>
<div class="header-right">
<div class="clock" id="clock">--:--:--</div>
<div class="connection-status"><div class="status-dot" id="wsStatus"></div><span id="wsStatusText">Verbinde...</span></div>
<div style="display:flex;align-items:center;gap:8px"><span style="font-size:12px;color:var(--text-secondary)">Update:</span><select id="updateInterval" onchange="setUpdateInterval(this.value)" style="padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:var(--card-bg);color:var(--text);font-size:12px"><option value="2000">2s</option><option value="5000">5s</option><option value="10000" selected>10s</option><option value="15000">15s</option></select></div><div style="display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:var(--card-bg)"><label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--text-secondary)" title="OPcache nach Aktivierung/Deaktivierung leeren"><input type="checkbox" id="autoFpmRestart" checked style="margin:0"><span>FPM-Restart</span></label></div>
<div class="update-dropdown" style="position:relative"><button class="btn-header" onclick="toggleUpdateDropdown()" id="updateDropdownBtn">🔄 v3.1</button><div id="updateDropdownMenu" style="display:none;position:absolute;right:0;top:100%;margin-top:4px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:8px 0;min-width:200px;z-index:1000;box-shadow:0 4px 12px rgba(0,0,0,0.3)"><button onclick="updateDashboard()" style="display:block;width:100%;text-align:left;padding:8px 16px;border:none;background:none;color:var(--text);cursor:pointer;font-size:13px" onmouseover="this.style.background='var(--bg)'" onmouseout="this.style.background='none'">📊 Dashboard updaten</button><button onclick="updateAgents()" style="display:block;width:100%;text-align:left;padding:8px 16px;border:none;background:none;color:var(--text);cursor:pointer;font-size:13px" onmouseover="this.style.background='var(--bg)'" onmouseout="this.style.background='none'">🖥️ Alle Agents updaten (<span id="agentCountDropdown">0</span>)</button></div></div>
<div class="notification-dropdown" style="position:relative"><button class="btn-header" onclick="toggleNotificationDropdown()" id="notificationBtn" title="Benachrichtigungen">🔔<span class="notification-badge" id="notificationBadge" style="display:none">0</span></button><div id="notificationDropdown" style="display:none;position:absolute;right:0;top:100%;margin-top:4px;background:#1a1a2e;border:1px solid rgba(255,255,255,0.15);border-radius:8px;min-width:350px;max-width:450px;z-index:1000;box-shadow:0 4px 20px rgba(0,0,0,0.5)"><div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,0.1);background:#1e1e32"><span style="font-weight:600;font-size:14px">📜 Benachrichtigungen</span><button onclick="clearNotifications()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px" title="Alle löschen">🗑️ Leeren</button></div><div id="notificationList" style="max-height:400px;overflow-y:auto;background:#1a1a2e"></div><div id="notificationEmpty" style="padding:24px;text-align:center;color:var(--text-secondary);font-size:13px;background:#1a1a2e">Keine Benachrichtigungen</div></div></div>
<div style="display:flex;gap:8px"><button class="btn-header" onclick="openPasswordModal()">🔑</button><a href="/logout" class="btn-header">Abmelden</a></div>
</div>
</header>
<main>
<div class="stats-grid">
<div class="stat-card"><div class="stat-label">Server Online</div><div class="stat-value success" id="statAgents">0</div></div>
<div class="stat-card"><div class="stat-label">Shops Aktiv</div><div class="stat-value" id="statShops">0/0</div></div>
<div class="stat-card"><div class="stat-label">🛡️ Link11</div><div class="stat-value link11" id="statLink11">0</div></div>
<div class="stat-card"><div class="stat-label">⚡ Direkt</div><div class="stat-value direct" id="statDirect">0</div></div>
<div class="stat-card"><div class="stat-label">🚫 Bans</div><div class="stat-value danger" id="statBans">0</div></div>
<div class="stat-card"><div class="stat-label">Human/min</div><div class="stat-value success" id="statHumanRpm">0</div></div>
<div class="stat-card"><div class="stat-label">Bot/min</div><div class="stat-value warning" id="statBotRpm">0</div></div>
</div>
<div class="top-shops-card">
<div class="top-shops-header" onclick="openAllShopsModal()">
<div><div class="top-shops-title">🔥 Top 10 Shops (Requests/min)</div><div class="top-shops-subtitle">Klicken für vollständige Liste</div></div>
<span style="color:var(--text-secondary)">→</span>
</div>
<div class="top-shops-list" id="topShopsList"></div>
</div>
<div class="bulk-actions">
<span class="bulk-actions-title">⚡ Massenaktionen:</span>
<button class="btn btn-success" onclick="openBulkActivateModal()">▶️ Aktivieren...</button>
<button class="btn btn-danger" onclick="openBulkDeactivateModal()">⏹️ Deaktivieren...</button>
</div>
<div class="section">
<div class="section-header"><h2 class="section-title">🖥️ Server</h2><span class="badge" id="agentCount">0</span></div>
<table id="tableAgents"><thead><tr><th class="sortable" onclick="sortAgents('status')">Status<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('hostname')">Hostname<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('shops')">Shops<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('link11')">Link11<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('human_rpm')">Human/min<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('bot_rpm')">Bot/min<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('load')">Load<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('memory')">Memory<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortAgents('last_seen')">Zuletzt<span class="sort-icon">⇅</span></th><th>Aktionen</th></tr></thead><tbody id="agentsTable"></tbody></table>
</div>
<div class="section">
<div class="section-header"><h2 class="section-title">🛡️ Shops hinter Link11</h2><span class="badge link11" id="link11Count">0</span></div>
<table id="tableLink11"><thead><tr><th class="sortable" onclick="sortShops('link11','status')">Status<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','domain')">Domain<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','server')">Server<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','modus')">Modus<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','req')">Req/min<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','bans')">Bans<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('link11','runtime')">Laufzeit<span class="sort-icon">⇅</span></th><th>Aktionen</th></tr></thead><tbody id="shopsLink11Table"></tbody></table>
</div>
<div class="section">
<div class="section-header"><h2 class="section-title">⚡ Shops direkt verbunden</h2><span class="badge direct" id="directCount">0</span></div>
<table id="tableDirect"><thead><tr><th class="sortable" onclick="sortShops('direct','status')">Status<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','domain')">Domain<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','server')">Server<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','modus')">Modus<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','req')">Req/min<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','bans')">Bans<span class="sort-icon">⇅</span></th><th class="sortable" onclick="sortShops('direct','runtime')">Laufzeit<span class="sort-icon">⇅</span></th><th>Aktionen</th></tr></thead><tbody id="shopsDirectTable"></tbody></table>
</div>
</main>
<div class="logs-panel enhanced" id="logsPanel">
<div class="logs-header">
<span>📜 Live Logs: <span id="logsShop">-</span></span>
<div style="display:flex;gap:8px;align-items:center">
<label style="font-size:12px;color:var(--text-secondary)">Zeitfenster:</label>
<select id="statsWindowSelect" onchange="changeStatsWindow(this.value)" style="padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:var(--card-bg);color:var(--text);font-size:12px">
<option value="5m" selected>5 min</option>
<option value="10m">10 min</option>
<option value="15m">15 min</option>
<option value="30m">30 min</option>
<option value="60m">60 min</option>
</select>
<button class="btn btn-secondary" onclick="closeLogs()">✕</button>
</div>
</div>
<div class="logs-grid">
<div class="logs-column logs-feed">
<div class="logs-column-header">📝 Live Feed</div>
<div class="logs-content" id="logsContent"></div>
</div>
<div class="logs-column logs-ips">
<div class="logs-column-header">🔝 Top IPs</div>
<div class="top-ips-list" id="topIpsList"></div>
<div class="logs-column-header" style="margin-top:12px">⚠️ Suspicious IPs</div>
<div class="suspicious-ips-list" id="suspiciousIpsList"></div>
</div>
<div class="logs-column logs-requests">
<div class="logs-column-header">📊 Top Requests</div>
<div class="top-requests-list" id="topRequestsList"></div>
<div class="logs-column-header" style="margin-top:12px">🚫 Top Blocked</div>
<div class="top-blocked-list" id="topBlockedList"></div>
</div>
</div>
</div>
<div class="modal-overlay" id="activateModal"><div class="modal"><h3 class="modal-title">🚀 Shop aktivieren (v2.5)</h3><form id="activateForm"><input type="hidden" name="domain" id="activateDomain"><div class="form-group"><label>Domain</label><input type="text" id="activateDomainDisplay" readonly></div><div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px" id="monitorOnlyGroup"><div class="form-group" style="margin:0"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="monitor_only" id="monitorOnlyCheck" onchange="toggleMonitorOnly()"> 🔍 Nur überwachen (kein Blocking)</label></div><small style="color:var(--text-secondary);display:block;margin-top:8px">Alle Anfragen werden protokolliert, aber nicht blockiert</small></div><div id="blockingOptions"><div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px"><div class="form-group" style="margin-bottom:12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="bot_mode" id="botModeCheck" checked onchange="toggleBotOptions()"> 🤖 Bot Rate-Limiting</label></div><div id="botOptionsGroup"><div class="form-group"><label>Bot Rate-Limit (Req/min pro Bot)</label><input type="number" name="bot_rate_limit" value="30" min="1"></div><div class="form-group"><label>Bot Ban-Dauer (Sekunden)</label><input type="number" name="bot_ban_duration" value="300" min="60"><small>300=5min</small></div></div></div><div style="border:1px solid var(--border);border-radius:8px;padding:16px"><div class="form-group" style="margin-bottom:12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="country_mode" id="countryModeCheck" onchange="toggleCountryOptions()"> 🌍 Country Rate-Limiting</label></div><div id="countryOptionsGroup" style="display:none"><div class="form-group"><label>Country Rate-Limit (Req/min pro Land)</label><input type="number" name="country_rate_limit" value="100" min="1"></div><div class="form-group"><label>Country Ban-Dauer (Sekunden)</label><input type="number" name="country_ban_duration" value="600" min="60"><small>600=10min</small></div><div class="form-group"><label>Unlimitierte Länder</label><input type="text" name="unlimited_countries" value="de,at,ch" placeholder="de,at,ch,us,gb"><small>Kommagetrennte ISO-Codes (z.B. de,at,ch). Diese Länder werden nicht limitiert.</small></div><div class="form-group"><label>Presets:</label><button type="button" class="btn btn-secondary" onclick="setCountries('dach')">🇩🇪🇦🇹🇨🇭 DACH</button> <button type="button" class="btn btn-secondary" onclick="setCountries('eu_plus')">🇪🇺 EU+</button></div></div></div></div><div class="modal-actions"><button type="button" class="btn btn-secondary" onclick="closeModal('activateModal')">Abbrechen</button><button type="submit" class="btn btn-primary">Aktivieren</button></div></form></div></div>
<div class="modal-overlay" id="bulkActivateModal"><div class="modal"><h3 class="modal-title">⚡ Massenaktivierung (v2.5)</h3><form id="bulkActivateForm"><div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px" id="bulkMonitorOnlyGroup"><div class="form-group" style="margin:0"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="monitor_only" id="bulkMonitorOnlyCheck" onchange="toggleBulkMonitorOnly()"> 🔍 Nur überwachen (kein Blocking)</label></div></div><div id="bulkBlockingOptions"><div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px"><div class="form-group" style="margin-bottom:12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="bot_mode" id="bulkBotModeCheck" checked onchange="toggleBulkBotOptions()"> 🤖 Bot Rate-Limiting</label></div><div id="bulkBotOptionsGroup"><div class="form-group"><label>Bot Rate-Limit (Req/min)</label><input type="number" name="bot_rate_limit" value="30" min="1"></div><div class="form-group"><label>Bot Ban-Dauer (Sek)</label><input type="number" name="bot_ban_duration" value="300" min="60"><small>300=5min</small></div></div></div><div style="border:1px solid var(--border);border-radius:8px;padding:16px"><div class="form-group" style="margin-bottom:12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;justify-content:flex-start"><input type="checkbox" name="country_mode" id="bulkCountryModeCheck" onchange="toggleBulkCountryOptions()"> 🌍 Country Rate-Limiting</label></div><div id="bulkCountryOptionsGroup" style="display:none"><div class="form-group"><label>Country Rate-Limit (Req/min)</label><input type="number" name="country_rate_limit" value="100" min="1"></div><div class="form-group"><label>Country Ban-Dauer (Sek)</label><input type="number" name="country_ban_duration" value="600" min="60"><small>600=10min</small></div><div class="form-group"><label>Unlimitierte Länder</label><input type="text" name="unlimited_countries" value="de,at,ch" placeholder="de,at,ch"><small>Kommagetrennte ISO-Codes</small></div><div class="form-group"><label>Presets:</label><button type="button" class="btn btn-secondary" onclick="setBulkCountries('dach')">🇩🇪🇦🇹🇨🇭 DACH</button> <button type="button" class="btn btn-secondary" onclick="setBulkCountries('eu_plus')">🇪🇺 EU+</button></div></div></div></div><div class="form-group" style="margin-top:16px"><label>Filter</label><select name="filter_type"><option value="all">Alle inaktiven</option><option value="direct">Nur Direkte ⚡</option><option value="link11">Nur Link11 🛡️</option></select></div><div class="modal-actions"><button type="button" class="btn btn-secondary" onclick="closeModal('bulkActivateModal')">Abbrechen</button><button type="submit" class="btn btn-success">▶️ Aktivieren</button></div></form></div></div>
<div class="modal-overlay" id="bulkDeactivateModal"><div class="modal"><h3 class="modal-title">⏹️ Massendeaktivierung</h3><form id="bulkDeactivateForm"><div class="form-group"><label>Filter</label><select name="filter_type"><option value="all">Alle aktiven</option><option value="direct">Nur Direkte ⚡</option><option value="link11">Nur Link11 🛡️</option></select></div><div class="modal-actions"><button type="button" class="btn btn-secondary" onclick="closeModal('bulkDeactivateModal')">Abbrechen</button><button type="submit" class="btn btn-danger">⏹️ Deaktivieren</button></div></form></div></div>
<div class="modal-overlay" id="passwordModal"><div class="modal"><h3 class="modal-title">🔑 Passwort ändern</h3><form id="passwordForm"><div class="form-group"><label>Aktuelles Passwort</label><input type="password" name="current" required></div><div class="form-group"><label>Neues Passwort</label><input type="password" name="new_pw" required minlength="8"></div><div class="form-group"><label>Bestätigen</label><input type="password" name="confirm" required></div><div class="modal-actions"><button type="button" class="btn btn-secondary" onclick="closeModal('passwordModal')">Abbrechen</button><button type="submit" class="btn btn-primary">Speichern</button></div></form></div></div>
<div class="modal-overlay" id="allShopsModal"><div class="modal xlarge"><h3 class="modal-title"><span>📊 Alle Shops</span><button class="btn btn-secondary" onclick="closeModal('allShopsModal')">✕</button></h3><div style="margin-bottom:16px"><button class="btn" id="sortByReq" onclick="sortAllShops('req_per_min')">Nach Requests</button> <button class="btn btn-secondary" id="sortByBans" onclick="sortAllShops('active_bans')">Nach Bans</button></div><table><thead><tr><th>#</th><th>Domain</th><th>Server</th><th>Status</th><th>Req/min</th><th>Bans</th><th>Typ</th></tr></thead><tbody id="allShopsTable"></tbody></table></div></div>
<div class="modal-overlay" id="detailModal"><div class="modal large"><h3 class="modal-title"><a href="#" id="detailDomainLink" target="_blank" style="color:var(--accent);text-decoration:none" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'"><span id="detailDomain">-</span> 🔗</a><button class="btn btn-secondary" onclick="closeModal('detailModal')">✕</button></h3><div style="color:var(--text-secondary);margin:-8px 0 16px 0;font-size:13px">Server: <span id="detailServer">-</span></div><div class="detail-grid"><div class="detail-card"><div class="detail-card-label">Status</div><div class="detail-card-value" id="detailStatus">-</div></div><div class="detail-card"><div class="detail-card-label">Modus</div><div class="detail-card-value" id="detailMode">-</div></div><div class="detail-card"><div class="detail-card-label">Unlimitiert</div><div class="detail-card-value" id="detailRegion">-</div></div><div class="detail-card"><div class="detail-card-label">Rate-Limit</div><div class="detail-card-value" id="detailRateLimit">-</div></div><div class="detail-card"><div class="detail-card-label">Ban-Dauer</div><div class="detail-card-value" id="detailBanDuration">-</div></div><div class="detail-card"><div class="detail-card-label">Laufzeit</div><div class="detail-card-value" id="detailRuntime">-</div></div></div><div class="detail-grid"><div class="detail-card"><div class="detail-card-label">Human/min</div><div class="detail-card-value success" id="detailHumanRpm">0</div></div><div class="detail-card"><div class="detail-card-label">Bot/min</div><div class="detail-card-value warning" id="detailBotRpm">0</div></div><div class="detail-card"><div class="detail-card-label">Aktive Bans</div><div class="detail-card-value" id="detailActiveBans">0</div></div><div class="detail-card"><div class="detail-card-label">Total Bans</div><div class="detail-card-value" id="detailTotalBans">0</div></div></div><div class="detail-section" id="detailActions"><div class="detail-section-title">⚙️ Steuerung</div><div style="display:flex;flex-wrap:wrap;gap:8px;padding:12px;background:var(--card-bg);border-radius:8px;align-items:center"><button class="btn btn-danger" id="detailBtnDeactivate" onclick="detailDeactivate()">⏹️ Deaktivieren</button><div style="border-left:1px solid var(--border);margin:0 8px;height:24px"></div><span style="color:var(--text-secondary);font-size:13px">Wechseln zu:</span><button class="btn" onclick="detailSwitchMode('bot-monitor')">🔍 Monitor</button><button class="btn" onclick="toggleRateLimitForm()">🤖 Bot-Limit ▾</button><button class="btn" onclick="detailSwitchMode('country-dach')">🇩🇪🇦🇹🇨🇭 DACH</button><button class="btn" onclick="detailSwitchMode('country-eu')">🇪🇺 EU+</button></div><div id="rateLimitFormArea" style="display:none;margin-top:12px;padding:12px;background:var(--card-bg);border-radius:8px;border:1px solid var(--border)"><div style="display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap"><div><label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px">Rate-Limit (Req/min)</label><input type="number" id="detailRateLimitInput" value="30" min="1" max="1000" style="width:100px;padding:8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text)"></div><div><label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px">Ban-Dauer</label><select id="detailBanDurationInput" style="padding:8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text)"><option value="60">1 Minute</option><option value="300" selected>5 Minuten</option><option value="600">10 Minuten</option><option value="1800">30 Minuten</option><option value="3600">1 Stunde</option><option value="86400">24 Stunden</option></select></div><button class="btn btn-primary" onclick="applyRateLimit()">✓ Anwenden</button><button class="btn btn-secondary" onclick="toggleRateLimitForm()">Abbrechen</button></div></div></div><div class="detail-section"><div class="detail-section-title">📈 Bot-Aktivität über Zeit</div><div class="chart-container"><canvas id="requestChart"></canvas></div><div class="chart-legend" id="chartLegend"></div></div><div class="detail-section"><div class="detail-section-title">🌍 Country-Aktivität über Zeit</div><div class="chart-container"><canvas id="countryChart"></canvas></div><div class="chart-legend" id="countryChartLegend"></div></div><div class="detail-section"><div class="detail-section-title">🤖 Top Bots</div><div class="bot-list" id="detailTopBots"></div></div><div class="detail-section"><div class="detail-section-title">🌍 Top Countries</div><div class="bot-list" id="detailTopCountries"></div></div><div class="detail-section"><div class="detail-section-title">🚫 Aktuell gebannt</div><div class="bot-list" id="detailBannedBots"></div></div></div></div>
<div class="modal-overlay" id="serverDetailModal"><div class="modal large"><h3 class="modal-title"><span>🖥️ <span id="serverDetailHostname">-</span></span><button class="btn btn-secondary" onclick="closeModal('serverDetailModal')">✕</button></h3><div style="color:var(--text-secondary);margin:-8px 0 16px 0;font-size:13px">Status: <span id="serverDetailStatus">-</span> | Load: <span id="serverDetailLoad">-</span> | Memory: <span id="serverDetailMemory">-</span></div><div class="detail-grid"><div class="detail-card"><div class="detail-card-label">Shops</div><div class="detail-card-value" id="serverDetailShops">0/0</div></div><div class="detail-card"><div class="detail-card-label">🤖 Bot-Mode</div><div class="detail-card-value" id="serverDetailBotMode">0</div></div><div class="detail-card"><div class="detail-card-label">🌍 Country-Mode</div><div class="detail-card-value" id="serverDetailCountryMode">0</div></div><div class="detail-card"><div class="detail-card-label">🔍 Monitor</div><div class="detail-card-value" id="serverDetailMonitor">0</div></div><div class="detail-card"><div class="detail-card-label">Human/min</div><div class="detail-card-value success" id="serverDetailHumanRpm">0</div></div><div class="detail-card"><div class="detail-card-label">Bot/min</div><div class="detail-card-value warning" id="serverDetailBotRpm">0</div></div></div><div class="detail-grid"><div class="detail-card"><div class="detail-card-label">🚫 Aktive Bans</div><div class="detail-card-value danger" id="serverDetailActiveBans">0</div></div><div class="detail-card"><div class="detail-card-label">Total Bans</div><div class="detail-card-value" id="serverDetailTotalBans">0</div></div><div class="detail-card"><div class="detail-card-label">🛡️ Link11</div><div class="detail-card-value link11" id="serverDetailLink11">0</div></div><div class="detail-card"><div class="detail-card-label">⚡ Direkt</div><div class="detail-card-value direct" id="serverDetailDirect">0</div></div></div><div class="detail-section"><div class="detail-section-title">⚙️ Server-Aktionen</div><div style="display:flex;flex-wrap:wrap;gap:8px;padding:12px;background:var(--card-bg);border-radius:8px;align-items:center"><button class="btn btn-success" onclick="serverActivateAll()">▶️ Alle aktivieren</button><button class="btn btn-danger" onclick="serverDeactivateAll()">⏹️ Alle deaktivieren</button><div style="border-left:1px solid var(--border);margin:0 8px;height:24px"></div><span style="color:var(--text-secondary);font-size:13px">Nur inaktive:</span><button class="btn" onclick="serverActivateInactive('bot')">🤖 Bot</button><button class="btn" onclick="serverActivateInactive('monitor')">🔍 Monitor</button><button class="btn" onclick="serverActivateInactive('dach')">🇩🇪🇦🇹🇨🇭 DACH</button><button class="btn" onclick="serverActivateInactive('eu')">🇪🇺 EU+</button></div></div><div class="detail-section"><div class="detail-section-title">📈 Bot-Aktivität über Zeit (Server-gesamt)</div><div class="chart-container"><canvas id="serverRequestChart"></canvas></div><div class="chart-legend" id="serverChartLegend"></div></div><div class="detail-section"><div class="detail-section-title">🌍 Country-Aktivität über Zeit (Server-gesamt)</div><div class="chart-container"><canvas id="serverCountryChart"></canvas></div><div class="chart-legend" id="serverCountryChartLegend"></div></div><div class="detail-section"><div class="detail-section-title">🤖 Top Bots (Server-gesamt)</div><div class="bot-list" id="serverDetailTopBots"></div></div><div class="detail-section"><div class="detail-section-title">🌍 Top Countries (Server-gesamt)</div><div class="bot-list" id="serverDetailTopCountries"></div></div><div class="detail-section"><div class="detail-section-title">🚫 Aktuell gebannt (Server-gesamt)</div><div class="bot-list" id="serverDetailBannedBots"></div></div><div class="detail-section"><div class="detail-section-title">📋 Shops auf diesem Server</div><div style="max-height:300px;overflow-y:auto"><table style="width:100%"><thead><tr><th>Status</th><th>Domain</th><th>Modus</th><th>Req/min</th><th>Bans</th></tr></thead><tbody id="serverShopsTable"></tbody></table></div></div></div></div>
<div class="toast-container" id="toastContainer"></div>
<script>
let ws=null,agents={},shops={},currentLogsShop=null,currentSortBy='req_per_min',currentDetailShop=null,currentDetailServer=null;
let updateInterval=10000,pendingRender=false,renderTimer=null;
let notifications=[],maxNotifications=50,unreadCount=0;
let serverBotChart=null,serverCountryChart=null,serverBotHistory={},serverCountryHistory={};
const BOT_COLORS=['#4a9eff','#00d26a','#ff4757','#ffc107','#9b59b6','#1abc9c','#e74c3c','#3498db','#f39c12','#2ecc71'];
function setUpdateInterval(ms){updateInterval=parseInt(ms);if(renderTimer)clearInterval(renderTimer);renderTimer=setInterval(()=>{if(pendingRender){renderAgents();renderShops();renderTopShops();refreshStats();pendingRender=false;}},updateInterval);}
function scheduleRender(){pendingRender=true;}
// Sort state für jede Tabelle
let sortState={agents:{col:null,asc:false},link11:{col:null,asc:false},direct:{col:null,asc:false}};
function updateSortIcons(tableId,col,asc){const table=document.getElementById(tableId);if(!table)return;table.querySelectorAll('th.sortable').forEach(th=>{th.classList.remove('asc','desc');const icon=th.querySelector('.sort-icon');if(icon)icon.textContent='';});if(col){const colMap={status:'sta',hostname:'hos',shops:'sho',link11:'lin',human_rpm:'hum',bot_rpm:'bot',load:'loa',memory:'mem',last_seen:'zul',domain:'dom',server:'ser',modus:'mod',req:'req',bans:'ban',runtime:'lau'};const matchStr=colMap[col]||col.substring(0,3);const ths=table.querySelectorAll('th.sortable');ths.forEach(th=>{if(th.textContent.replace('','').trim().toLowerCase().includes(matchStr)){th.classList.add(asc?'asc':'desc');const icon=th.querySelector('.sort-icon');if(icon)icon.textContent=asc?'':'';}});}}
function sortAgents(col){const st=sortState.agents;if(st.col===col){st.asc=!st.asc;}else{st.col=col;st.asc=false;}renderAgents();updateSortIcons('agentsTable',col,st.asc);}
function sortShops(type,col){const st=sortState[type];if(st.col===col){st.asc=!st.asc;}else{st.col=col;st.asc=false;}renderShops();const tableId=type==='link11'?'tableLink11':'tableDirect';updateSortIcons(tableId,col,st.asc);}
function getSortedAgents(){const list=Object.values(agents);const st=sortState.agents;if(!st.col)return list;const dir=st.asc?1:-1;return list.sort((a,b)=>{let va,vb;switch(st.col){case'status':const order={online:3,pending:2,offline:1};va=order[a.status]||0;vb=order[b.status]||0;break;case'hostname':va=(a.hostname||'').toLowerCase();vb=(b.hostname||'').toLowerCase();return dir*(va<vb?-1:va>vb?1:0);case'shops':va=(a.shops_active||0);vb=(b.shops_active||0);break;case'link11':const l11A=getAgentLink11(a.id);va=l11A.link11;const l11B=getAgentLink11(b.id);vb=l11B.link11;break;case'human_rpm':const rpmA=getAgentRpm(a.id);va=rpmA.humanRpm;const rpmB_h=getAgentRpm(b.id);vb=rpmB_h.humanRpm;break;case'bot_rpm':const rpmA_b=getAgentRpm(a.id);va=rpmA_b.botRpm;const rpmB_b=getAgentRpm(b.id);vb=rpmB_b.botRpm;break;case'load':va=(a.load_1m||0);vb=(b.load_1m||0);break;case'memory':va=(a.memory_percent||0);vb=(b.memory_percent||0);break;case'last_seen':va=a.last_seen||'';vb=b.last_seen||'';return dir*(va<vb?-1:va>vb?1:0);default:return 0;}return dir*(va-vb);});}
function getSortedShops(list,type){const st=sortState[type];if(!st.col)return list;const dir=st.asc?1:-1;return list.sort((a,b)=>{let va,vb;switch(st.col){case'status':va=a.status==='active'?1:0;vb=b.status==='active'?1:0;break;case'domain':va=(a.domain||'').toLowerCase();vb=(b.domain||'').toLowerCase();return dir*(va<vb?-1:va>vb?1:0);case'server':va=(a.agent_hostname||'').toLowerCase();vb=(b.agent_hostname||'').toLowerCase();return dir*(va<vb?-1:va>vb?1:0);case'modus':va=(a.mode||'').toLowerCase();vb=(b.mode||'').toLowerCase();return dir*(va<vb?-1:va>vb?1:0);case'req':va=(a.stats?.req_per_min||0);vb=(b.stats?.req_per_min||0);break;case'bans':va=((a.stats?.active_bot_bans||0)+(a.stats?.active_country_bans||0));vb=((b.stats?.active_bot_bans||0)+(b.stats?.active_country_bans||0));break;case'runtime':va=a.activated?new Date(a.activated).getTime():0;vb=b.activated?new Date(b.activated).getTime():0;break;default:return 0;}return dir*(va-vb);});}
function updateClock(){document.getElementById('clock').textContent=new Date().toLocaleTimeString('de-DE');}
setInterval(updateClock,1000);updateClock();
function connect(){const p=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(p+'//'+location.host+'/ws/dashboard');ws.onopen=()=>{document.getElementById('wsStatus').classList.add('connected');document.getElementById('wsStatusText').textContent='Verbunden';};ws.onclose=()=>{document.getElementById('wsStatus').classList.remove('connected');document.getElementById('wsStatusText').textContent='Getrennt';setTimeout(connect,3000);};ws.onmessage=e=>handleMessage(JSON.parse(e.data));}
function handleMessage(msg){switch(msg.type){case'initial_state':case'refresh':agents={};msg.data.agents.forEach(a=>agents[a.id]=a);shops={};msg.data.shops.forEach(s=>shops[s.domain]=s);updateStats(msg.data.stats);renderAgents();renderShops();renderTopShops();break;case'agent.online':case'agent.update':case'agent.pending':const a=msg.data;if(!agents[a.agent_id])agents[a.agent_id]={};Object.assign(agents[a.agent_id],{id:a.agent_id,hostname:a.hostname,status:a.status||'online',approved:a.approved,shops_total:a.shops_summary?.total||0,shops_active:a.shops_summary?.active||0,load_1m:a.system?.load_1m,memory_percent:a.system?.memory_percent,last_seen:new Date().toISOString()});scheduleRender();break;case'agent.offline':if(agents[msg.data.agent_id]){agents[msg.data.agent_id].status='offline';scheduleRender();}break;case'agent.approved':if(agents[msg.data.agent_id]){agents[msg.data.agent_id].status='online';agents[msg.data.agent_id].approved=true;scheduleRender();toast('Agent freigegeben','success');}break;case'shop.full_update':msg.data.shops.forEach(s=>{shops[s.domain]={...s,agent_id:msg.data.agent_id,agent_hostname:msg.data.hostname};});scheduleRender();break;case'shop.stats':if(shops[msg.data.domain]){shops[msg.data.domain].stats=msg.data.stats;scheduleRender();}break;case'shop_history':updateBotChart(msg.data);updateCountryChart(msg.data);break;case'top_shops':case'all_shops_sorted':renderAllShopsTable(msg.data.shops,msg.data.sort_by);break;case'log.entry':if(msg.data.shop===currentLogsShop)addLogEntry(msg.data.line);break;case'bot.banned':toast('🚫 '+msg.data.bot_name+' gebannt','warning');break;case'command.result':toast(msg.data.message,msg.data.status==='success'?'success':'error');break;case'livestats.result':renderLiveStats(msg.data);break;}}
function getAgentRpm(agentId){const agentShops=Object.values(shops).filter(s=>s.agent_id===agentId);let humanRpm=0,botRpm=0;agentShops.forEach(s=>{const st=s.stats||{};humanRpm+=(st.human_rpm||0);botRpm+=(st.bot_rpm||0);});return {humanRpm,botRpm};}
function getAgentLink11(agentId){const agentShops=Object.values(shops).filter(s=>s.agent_id===agentId);const link11Count=agentShops.filter(s=>s.link11).length;return {link11:link11Count,total:agentShops.length};}
function renderAgents(){const t=document.getElementById('agentsTable'),l=getSortedAgents();document.getElementById('agentCount').textContent=l.length+' Agents';document.getElementById('agentCountDropdown').textContent=l.length;t.innerHTML=l.map(a=>{const rpm=getAgentRpm(a.id);const l11=getAgentLink11(a.id);return '<tr><td><span class="status-badge status-'+(a.status||'offline')+'">'+(a.status==='online'?'🟢':a.status==='pending'?'🟡':'🔴')+' '+(a.status==='pending'?'Warte':a.status==='online'?'Online':'Offline')+'</span></td><td><span class="domain-link" onclick="openServerDetailModal(\\''+a.id+'\\')"><strong>'+a.hostname+'</strong></span></td><td>'+(a.shops_active||0)+'/'+(a.shops_total||0)+'</td><td>'+l11.link11+'/'+l11.total+'</td><td class="success">'+rpm.humanRpm.toFixed(1)+'</td><td class="warning">'+rpm.botRpm.toFixed(1)+'</td><td>'+(a.load_1m!=null?a.load_1m.toFixed(2):'-')+'</td><td>'+(a.memory_percent!=null?a.memory_percent.toFixed(1)+'%':'-')+'</td><td>'+(a.last_seen?formatTime(a.last_seen):'-')+'</td><td>'+(a.status==='pending'?'<button class="btn btn-primary" onclick="approveAgent(\\''+a.id+'\\')">✓</button>':'')+'</td></tr>';}).join('');}
function renderShops(){const all=Object.values(shops);let l11=all.filter(s=>s.link11),dir=all.filter(s=>!s.link11);l11=getSortedShops(l11,'link11');dir=getSortedShops(dir,'direct');document.getElementById('link11Count').textContent=l11.length+' Shops';document.getElementById('directCount').textContent=dir.length+' Shops';document.getElementById('shopsLink11Table').innerHTML=renderShopRows(l11);document.getElementById('shopsDirectTable').innerHTML=renderShopRows(dir);}
function renderShopRows(l){return l.map(s=>{const modeStr=s.monitor_only?'🔍 Monitor':((s.bot_mode?'🤖':'')+(s.country_mode?' 🌍':''))||'-';const bansStr=s.monitor_only?'-':((s.stats?.active_bot_bans||0)+(s.stats?.active_country_bans||0));return '<tr><td><span class="status-badge status-'+(s.status||'inactive')+'">'+(s.status==='active'?'':'')+'</span></td><td><span class="domain-link" onclick="openDetailModal(\\''+s.domain+'\\')">'+s.domain+'</span></td><td>'+(s.agent_hostname||'-')+'</td><td>'+modeStr+'</td><td>'+((s.stats?.req_per_min||0).toFixed(1))+'</td><td>'+bansStr+'</td><td>'+formatRuntime(s.activated)+'</td><td class="actions"><a href="https://'+s.domain+'" target="_blank" class="btn-icon">🔗</a>'+(s.status==='active'?'<button class="btn-icon" onclick="openLogs(\\''+s.domain+'\\')">📜</button><button class="btn btn-danger" onclick="deactivateShop(\\''+s.domain+'\\')">Stop</button>':'<button class="btn btn-primary" onclick="openActivateModal(\\''+s.domain+'\\')">Start</button>')+'</td></tr>';}).join('');}
function renderTopShops(){const l=Object.values(shops).filter(s=>s.status==='active').sort((a,b)=>(b.stats?.req_per_min||0)-(a.stats?.req_per_min||0)).slice(0,10);document.getElementById('topShopsList').innerHTML=l.map(s=>{const bans=(s.stats?.active_bot_bans||0)+(s.stats?.active_country_bans||0);return '<div class="top-shop-item" onclick="openDetailModal(\\''+s.domain+'\\')"><div class="top-shop-domain">'+s.domain+'</div><div class="top-shop-stats"><span class="top-shop-req">'+((s.stats?.req_per_min||0).toFixed(1))+' req/m</span><span class="top-shop-bans">'+bans+' bans</span></div></div>';}).join('')||'<div style="color:var(--text-secondary)">Keine aktiven Shops</div>';}
function updateStats(s){document.getElementById('statAgents').textContent=s.agents_online||0;document.getElementById('statShops').textContent=(s.shops_active||0)+'/'+(s.shops_total||0);document.getElementById('statLink11').textContent=s.shops_link11||0;document.getElementById('statDirect').textContent=s.shops_direct||0;document.getElementById('statBans').textContent=s.active_bans||0;document.getElementById('statHumanRpm').textContent=(s.human_rpm||0).toFixed(1);document.getElementById('statBotRpm').textContent=(s.bot_rpm||0).toFixed(1);}
function refreshStats(){const l=Object.values(shops),a=l.filter(s=>s.status==='active');document.getElementById('statShops').textContent=a.length+'/'+l.length;document.getElementById('statLink11').textContent=l.filter(s=>s.link11).length;document.getElementById('statDirect').textContent=l.filter(s=>!s.link11).length;document.getElementById('statBans').textContent=l.reduce((sum,s)=>sum+(s.stats?.active_bot_bans||0)+(s.stats?.active_country_bans||0),0);document.getElementById('statHumanRpm').textContent=a.reduce((sum,s)=>sum+(s.stats?.human_rpm||0),0).toFixed(1);document.getElementById('statBotRpm').textContent=a.reduce((sum,s)=>sum+(s.stats?.bot_rpm||0),0).toFixed(1);}
async function approveAgent(id){await fetch('/api/agents/'+id+'/approve',{method:'POST'});}
function openActivateModal(d){document.getElementById('activateDomain').value=d;document.getElementById('activateDomainDisplay').value=d;document.getElementById('monitorOnlyCheck').checked=false;document.getElementById('botModeCheck').checked=true;document.getElementById('countryModeCheck').checked=false;toggleMonitorOnly();toggleBotOptions();toggleCountryOptions();document.getElementById('activateModal').classList.add('open');}
function closeModal(id){document.getElementById(id).classList.remove('open');}
function toggleMonitorOnly(){const isMonitor=document.getElementById('monitorOnlyCheck').checked;document.getElementById('blockingOptions').style.display=isMonitor?'none':'block';}
function toggleBotOptions(){const isBot=document.getElementById('botModeCheck').checked;document.getElementById('botOptionsGroup').style.display=isBot?'block':'none';}
function toggleCountryOptions(){const isCountry=document.getElementById('countryModeCheck').checked;document.getElementById('countryOptionsGroup').style.display=isCountry?'block':'none';}
function setCountries(preset){const input=document.querySelector('#activateForm input[name="unlimited_countries"]');if(preset==='dach')input.value='de,at,ch';else if(preset==='eu_plus')input.value='de,at,ch,be,cy,ee,es,fi,fr,gb,gr,hr,ie,it,lt,lu,lv,mt,nl,pt,si,sk';}
document.getElementById('activateForm').onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target);fd.set('bot_mode',document.getElementById('botModeCheck').checked?'true':'false');fd.set('country_mode',document.getElementById('countryModeCheck').checked?'true':'false');fd.set('monitor_only',document.getElementById('monitorOnlyCheck').checked?'true':'false');fd.set('restart_fpm',document.getElementById('autoFpmRestart').checked?'true':'false');await fetch('/api/shops/activate',{method:'POST',body:fd});closeModal('activateModal');};
async function deactivateShop(d){if(!confirm(d+' deaktivieren?'))return;const fd=new FormData();fd.append('domain',d);fd.append('restart_fpm',document.getElementById('autoFpmRestart').checked?'true':'false');await fetch('/api/shops/deactivate',{method:'POST',body:fd});}
function openBulkActivateModal(){document.getElementById('bulkMonitorOnlyCheck').checked=false;document.getElementById('bulkBotModeCheck').checked=true;document.getElementById('bulkCountryModeCheck').checked=false;toggleBulkMonitorOnly();toggleBulkBotOptions();toggleBulkCountryOptions();document.getElementById('bulkActivateModal').classList.add('open');}
function openBulkDeactivateModal(){document.getElementById('bulkDeactivateModal').classList.add('open');}
function toggleBulkMonitorOnly(){const isMonitor=document.getElementById('bulkMonitorOnlyCheck').checked;document.getElementById('bulkBlockingOptions').style.display=isMonitor?'none':'block';}
function toggleBulkBotOptions(){const isBot=document.getElementById('bulkBotModeCheck').checked;document.getElementById('bulkBotOptionsGroup').style.display=isBot?'block':'none';}
function toggleBulkCountryOptions(){const isCountry=document.getElementById('bulkCountryModeCheck').checked;document.getElementById('bulkCountryOptionsGroup').style.display=isCountry?'block':'none';}
function setBulkCountries(preset){const input=document.querySelector('#bulkActivateForm input[name="unlimited_countries"]');if(preset==='dach')input.value='de,at,ch';else if(preset==='eu_plus')input.value='de,at,ch,be,cy,ee,es,fi,fr,gb,gr,hr,ie,it,lt,lu,lv,mt,nl,pt,si,sk';}
document.getElementById('bulkActivateForm').onsubmit=async e=>{e.preventDefault();if(!confirm('Shops aktivieren?'))return;const fd=new FormData(e.target);fd.set('bot_mode',document.getElementById('bulkBotModeCheck').checked?'true':'false');fd.set('country_mode',document.getElementById('bulkCountryModeCheck').checked?'true':'false');fd.set('monitor_only',document.getElementById('bulkMonitorOnlyCheck').checked?'true':'false');fd.set('restart_fpm',document.getElementById('autoFpmRestart').checked?'true':'false');closeModal('bulkActivateModal');toast('Aktivierung...','info');const r=await fetch('/api/shops/bulk-activate',{method:'POST',body:fd});const d=await r.json();if(d.success)toast(d.activated+' aktiviert','success');};
document.getElementById('bulkDeactivateForm').onsubmit=async e=>{e.preventDefault();if(!confirm('Shops deaktivieren?'))return;const fd=new FormData(e.target);fd.set('restart_fpm',document.getElementById('autoFpmRestart').checked?'true':'false');closeModal('bulkDeactivateModal');toast('Deaktivierung...','info');const r=await fetch('/api/shops/bulk-deactivate',{method:'POST',body:fd});const d=await r.json();if(d.success)toast(d.deactivated+' deaktiviert','success');};
function openPasswordModal(){document.getElementById('passwordModal').classList.add('open');}
document.getElementById('passwordForm').onsubmit=async e=>{e.preventDefault();const r=await fetch('/api/change-password',{method:'POST',body:new FormData(e.target)});const d=await r.json();if(d.success){toast('Passwort geändert','success');closeModal('passwordModal');e.target.reset();}else toast(d.error,'error');};
function openAllShopsModal(){document.getElementById('allShopsModal').classList.add('open');sortAllShops('req_per_min');}
function sortAllShops(by){currentSortBy=by;document.getElementById('sortByReq').className='btn'+(by==='req_per_min'?' btn-primary':' btn-secondary');document.getElementById('sortByBans').className='btn'+(by==='active_bans'?' btn-primary':' btn-secondary');if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'get_all_shops_sorted',data:{sort_by:by}}));}
function renderAllShopsTable(l,by){document.getElementById('allShopsTable').innerHTML=l.map((s,i)=>{const bans=(s.active_bot_bans||0)+(s.active_country_bans||0);return '<tr onclick="openDetailModal(\\''+s.domain+'\\')" style="cursor:pointer"><td>'+(i+1)+'</td><td><strong>'+s.domain+'</strong></td><td>'+(s.agent_hostname||'-')+'</td><td><span class="status-badge status-'+(s.status||'inactive')+'">'+(s.status==='active'?'':'')+'</span></td><td style="'+(by==='req_per_min'?'color:var(--accent);font-weight:600':'')+'">'+(s.req_per_min||0).toFixed(1)+'</td><td style="'+(by==='active_bans'?'color:var(--warning);font-weight:600':'')+'">'+bans+'</td><td>'+(s.link11?'🛡️':'')+'</td></tr>';}).join('');}
function openDetailModal(d){currentDetailShop=d;const s=shops[d];if(!s)return;document.getElementById('detailDomain').textContent=d;document.getElementById('detailDomainLink').href='https://'+d;document.getElementById('detailServer').textContent=s.agent_hostname||'-';document.getElementById('detailStatus').textContent=s.status==='active'?'✅ Aktiv':'⭕ Inaktiv';const modeStr=s.monitor_only?'🔍 Monitor':((s.bot_mode?'🤖 Bot':'')+(s.country_mode?' 🌍 Country':''))||'-';document.getElementById('detailMode').textContent=modeStr;const unlimitedStr=s.unlimited_countries&&s.unlimited_countries.length>0?s.unlimited_countries.map(c=>c.toUpperCase()).join(', '):(s.country_mode?'(keine)':'-');document.getElementById('detailRegion').textContent=unlimitedStr;const rlStr=s.monitor_only?'- (Monitor)':(s.bot_mode&&s.bot_rate_limit?'Bot:'+s.bot_rate_limit+'/min':'')+(s.country_mode&&s.country_rate_limit?(s.bot_mode?' | ':'')+'Country:'+s.country_rate_limit+'/min':'')||'-';document.getElementById('detailRateLimit').textContent=rlStr;const bdStr=s.monitor_only?'- (Monitor)':(s.bot_mode&&s.bot_ban_duration?'Bot:'+(s.bot_ban_duration>=60?Math.round(s.bot_ban_duration/60)+'m':s.bot_ban_duration+'s'):'')+(s.country_mode&&s.country_ban_duration?(s.bot_mode?' | ':'')+'Country:'+(s.country_ban_duration>=60?Math.round(s.country_ban_duration/60)+'m':s.country_ban_duration+'s'):'')||'-';document.getElementById('detailBanDuration').textContent=bdStr;document.getElementById('detailRuntime').textContent=formatRuntime(s.activated);const st=s.stats||{};document.getElementById('detailHumanRpm').textContent=(st.human_rpm||0).toFixed(1);document.getElementById('detailBotRpm').textContent=(st.bot_rpm||0).toFixed(1);const activeBans=s.monitor_only?'-':((st.active_bot_bans||0)+(st.active_country_bans||0));document.getElementById('detailActiveBans').textContent=activeBans;const totalBans=s.monitor_only?'-':((st.bot_bans||0)+(st.country_bans||0));document.getElementById('detailTotalBans').textContent=totalBans;document.getElementById('detailTopBots').innerHTML=Object.entries(st.top_bots||{}).sort((a,b)=>b[1]-a[1]).map(([n,c])=>'<div class="bot-item"><span>'+n+'</span><span>'+c+'</span></div>').join('')||'<div style="color:var(--text-secondary);padding:8px">Keine Daten</div>';document.getElementById('detailTopCountries').innerHTML=Object.entries(st.top_countries||{}).sort((a,b)=>b[1]-a[1]).map(([n,c])=>'<div class="bot-item"><span>'+n.toUpperCase()+'</span><span>'+c+'</span></div>').join('')||'<div style="color:var(--text-secondary);padding:8px">Keine Daten</div>';const bannedList=(st.banned_bots||[]).concat(st.banned_countries||[]);document.getElementById('detailBannedBots').innerHTML=s.monitor_only?'<div style="color:var(--text-secondary);padding:8px">Monitor-Only (keine Bans)</div>':bannedList.map(n=>'<div class="bot-item"><span>🚫 '+n+'</span></div>').join('')||'<div style="color:var(--text-secondary);padding:8px">Keine Bans</div>';document.getElementById('detailBtnDeactivate').style.display=s.status==='active'?'inline-block':'none';document.getElementById('rateLimitFormArea').style.display='none';document.getElementById('detailRateLimitInput').value=s.bot_rate_limit||30;const bd=s.bot_ban_duration||300;document.getElementById('detailBanDurationInput').value=[60,300,600,1800,3600,86400].includes(bd)?bd:300;document.getElementById('chartLegend').innerHTML='';document.getElementById('countryChartLegend').innerHTML='';const cv=document.getElementById('requestChart');cv.getContext('2d').clearRect(0,0,cv.width,cv.height);const ccv=document.getElementById('countryChart');ccv.getContext('2d').clearRect(0,0,ccv.width,ccv.height);if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'get_shop_history',data:{domain:d}}));document.getElementById('detailModal').classList.add('open');}
function updateBotChart(data){const cv=document.getElementById('requestChart'),ctx=cv.getContext('2d'),ct=cv.parentElement,w=ct.clientWidth-32,h=230;cv.width=w;cv.height=h;ctx.clearRect(0,0,w,h);const bh=data.bot_history||{},bn=Object.keys(bh).slice(0,10);if(bn.length===0){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Noch keine Bot-Daten',w/2-60,h/2);return;}let ts=new Set();bn.forEach(b=>bh[b].forEach(p=>ts.add(p.timestamp)));ts=[...ts].sort();if(ts.length<2){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Warte auf Daten...',w/2-50,h/2);return;}let mx=1;bn.forEach(b=>bh[b].forEach(p=>{if(p.count>mx)mx=p.count;}));const pd={t:20,r:20,b:40,l:50},cW=w-pd.l-pd.r,cH=h-pd.t-pd.b;ctx.strokeStyle='rgba(255,255,255,0.1)';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pd.t+(cH/4)*i;ctx.beginPath();ctx.moveTo(pd.l,y);ctx.lineTo(w-pd.r,y);ctx.stroke();ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';ctx.fillText(Math.round(mx-(mx/4)*i),5,y+4);}const step=Math.max(1,Math.floor(ts.length/6));ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';for(let i=0;i<ts.length;i+=step){const t=ts[i].split(' ')[1]?.substring(0,5)||ts[i],x=pd.l+(cW/(ts.length-1))*i;ctx.fillText(t,x-15,h-10);}const lg=document.getElementById('chartLegend');lg.innerHTML='';bn.forEach((bot,idx)=>{const c=BOT_COLORS[idx%BOT_COLORS.length],pts=bh[bot];ctx.strokeStyle=c;ctx.lineWidth=2;ctx.beginPath();pts.forEach((p,i)=>{const ti=ts.indexOf(p.timestamp),x=pd.l+(cW/(ts.length-1))*ti,y=pd.t+cH-(p.count/mx)*cH;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});ctx.stroke();lg.innerHTML+='<div class="legend-item"><div class="legend-color" style="background:'+c+'"></div><span>'+bot+'</span></div>';});}
function updateCountryChart(data){const cv=document.getElementById('countryChart'),ctx=cv.getContext('2d'),ct=cv.parentElement,w=ct.clientWidth-32,h=230;cv.width=w;cv.height=h;ctx.clearRect(0,0,w,h);const ch=data.country_history||{},cn=Object.keys(ch).slice(0,10);if(cn.length===0){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Noch keine Country-Daten',w/2-70,h/2);return;}let ts=new Set();cn.forEach(c=>ch[c].forEach(p=>ts.add(p.timestamp)));ts=[...ts].sort();if(ts.length<2){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Warte auf Daten...',w/2-50,h/2);return;}let mx=1;cn.forEach(c=>ch[c].forEach(p=>{if(p.count>mx)mx=p.count;}));const pd={t:20,r:20,b:40,l:50},cW=w-pd.l-pd.r,cH=h-pd.t-pd.b;ctx.strokeStyle='rgba(255,255,255,0.1)';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pd.t+(cH/4)*i;ctx.beginPath();ctx.moveTo(pd.l,y);ctx.lineTo(w-pd.r,y);ctx.stroke();ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';ctx.fillText(Math.round(mx-(mx/4)*i),5,y+4);}const step=Math.max(1,Math.floor(ts.length/6));ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';for(let i=0;i<ts.length;i+=step){const t=ts[i].split(' ')[1]?.substring(0,5)||ts[i],x=pd.l+(cW/(ts.length-1))*i;ctx.fillText(t,x-15,h-10);}const lg=document.getElementById('countryChartLegend');lg.innerHTML='';cn.forEach((country,idx)=>{const clr=BOT_COLORS[idx%BOT_COLORS.length],pts=ch[country];ctx.strokeStyle=clr;ctx.lineWidth=2;ctx.beginPath();pts.forEach((p,i)=>{const ti=ts.indexOf(p.timestamp),x=pd.l+(cW/(ts.length-1))*ti,y=pd.t+cH-(p.count/mx)*cH;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});ctx.stroke();lg.innerHTML+='<div class="legend-item"><div class="legend-color" style="background:'+clr+'"></div><span>'+country.toUpperCase()+'</span></div>';});}
let currentStatsWindow='5m',liveStatsInterval=null;
function openLogs(d){currentLogsShop=d;document.getElementById('logsShop').textContent=d;document.getElementById('logsContent').innerHTML='<div style="color:#666">Warte auf Logs...</div>';document.getElementById('topIpsList').innerHTML='<div style="color:#666;padding:8px">Lade...</div>';document.getElementById('suspiciousIpsList').innerHTML='<div style="color:#666;padding:8px">Lade...</div>';document.getElementById('topRequestsList').innerHTML='<div style="color:#666;padding:8px">Lade...</div>';document.getElementById('topBlockedList').innerHTML='<div style="color:#666;padding:8px">Lade...</div>';document.getElementById('logsPanel').classList.add('open');if(ws&&ws.readyState===1){ws.send(JSON.stringify({type:'log.subscribe',data:{shop:d}}));requestLiveStats();}if(liveStatsInterval)clearInterval(liveStatsInterval);liveStatsInterval=setInterval(requestLiveStats,5000);}
function closeLogs(){if(currentLogsShop&&ws&&ws.readyState===1)ws.send(JSON.stringify({type:'log.unsubscribe',data:{shop:currentLogsShop}}));currentLogsShop=null;document.getElementById('logsPanel').classList.remove('open');if(liveStatsInterval){clearInterval(liveStatsInterval);liveStatsInterval=null;}}
function changeStatsWindow(w){currentStatsWindow=w;requestLiveStats();}
function requestLiveStats(){if(!currentLogsShop||!ws||ws.readyState!==1)return;ws.send(JSON.stringify({type:'command.livestats',data:{shop:currentLogsShop,window:currentStatsWindow}}));}
function renderLiveStats(data){if(!data.success||!data.stats)return;const st=data.stats;document.getElementById('topIpsList').innerHTML=renderTopIps(st.top_ips||[]);document.getElementById('suspiciousIpsList').innerHTML=renderSuspiciousIps(st.suspicious_ips||[]);document.getElementById('topRequestsList').innerHTML=renderRequests(st.top_requests||[]);document.getElementById('topBlockedList').innerHTML=renderRequests(st.top_blocked||[],'blocked');}
function renderTopIps(ips){if(!ips.length)return '<div style="color:#666;padding:8px">Keine Daten</div>';return ips.slice(0,10).map(ip=>'<div class="ip-item"><div class="ip-info"><span class="ip-addr">'+ip.ip+'</span><span class="ip-meta">'+ip.country+' | '+(ip.org||ip.asn||'-')+'</span></div><span class="ip-count">'+ip.count+'x</span><div class="ip-actions"><button class="btn-ban" onclick="quickBan(\\''+ip.ip+'\\')">🚫</button><button class="btn-whitelist" onclick="quickWhitelist(\\''+ip.ip+'\\')">✓</button></div></div>').join('');}
function renderSuspiciousIps(ips){if(!ips.length)return '<div style="color:#666;padding:8px">Keine verdächtigen IPs</div>';return ips.slice(0,5).map(ip=>'<div class="ip-item suspicious-item"><div class="ip-info"><span class="ip-addr">'+ip.ip+'</span><span class="ip-meta">'+ip.country+' | '+ip.reason+(ip.errors?' | '+ip.errors+' Errors':'')+'</span></div><span class="ip-count">'+ip.count+'x</span><div class="ip-actions"><button class="btn-ban" onclick="quickBan(\\''+ip.ip+'\\')">🚫</button></div></div>').join('');}
function renderRequests(reqs,type){if(!reqs.length)return '<div style="color:#666;padding:8px">Keine Daten</div>';return reqs.slice(0,10).map(r=>'<div class="request-item'+(type==='blocked'?' blocked':'')+'"><span class="request-path" title="'+r.path+'">'+r.path+'</span><span class="request-count">'+r.count+'x</span></div>').join('');}
function quickBan(ip){if(!currentLogsShop)return;const duration=prompt('Ban-Dauer auswählen:\\n\\n900 = 15 Minuten\\n3600 = 1 Stunde\\n21600 = 6 Stunden\\n86400 = 24 Stunden\\n604800 = 7 Tage\\n-1 = Permanent','3600');if(duration===null)return;ws.send(JSON.stringify({type:'command.ban',data:{shop:currentLogsShop,ip:ip,duration:parseInt(duration),reason:'Manual ban from dashboard'}}));toast('IP '+ip+' wird gebannt...','success');}
function quickWhitelist(ip){if(!currentLogsShop)return;const desc=prompt('Beschreibung (optional):','');if(desc===null)return;ws.send(JSON.stringify({type:'command.whitelist',data:{shop:currentLogsShop,ip:ip,description:desc}}));toast('IP '+ip+' wird gewhitelisted...','success');}
function addLogEntry(line){const c=document.getElementById('logsContent');if(c.querySelector('div[style*="color:#666"]'))c.innerHTML='';const e=document.createElement('div');e.className='log-entry'+(line.includes('BANNED')?' banned':'');e.textContent=line;c.insertBefore(e,c.firstChild);while(c.children.length>100)c.removeChild(c.lastChild);}
function formatTime(iso){return new Date(iso).toLocaleTimeString('de-DE');}
function formatRuntime(activated){if(!activated)return'-';const start=new Date(activated);const now=new Date();const m=(now-start)/60000;if(m<=0)return'-';if(m<60)return Math.round(m)+'m';const h=m/60;if(h<24)return Math.round(h)+'h';return Math.round(h/24)+'d';}
function toast(msg,type='info'){const c=document.getElementById('toastContainer'),t=document.createElement('div');t.className='toast '+type;t.innerHTML='<span>'+msg+'</span>';c.appendChild(t);setTimeout(()=>t.remove(),4000);addNotification(msg,type);}
function addNotification(msg,type='info'){const icons={success:'',error:'',warning:'⚠️',info:''};const n={id:Date.now(),message:msg,type:type,icon:icons[type]||'',time:new Date()};notifications.unshift(n);if(notifications.length>maxNotifications)notifications.pop();unreadCount++;updateNotificationBadge();renderNotifications();}
function updateNotificationBadge(){const badge=document.getElementById('notificationBadge');if(unreadCount>0){badge.textContent=unreadCount>99?'99+':unreadCount;badge.style.display='flex';}else{badge.style.display='none';}}
function renderNotifications(){const list=document.getElementById('notificationList'),empty=document.getElementById('notificationEmpty');if(notifications.length===0){list.innerHTML='';empty.style.display='block';return;}empty.style.display='none';list.innerHTML=notifications.map(n=>'<div class="notification-item"><span class="notification-icon">'+n.icon+'</span><div class="notification-content"><div class="notification-text">'+n.message+'</div><div class="notification-time">'+formatNotificationTime(n.time)+'</div></div></div>').join('');}
function formatNotificationTime(date){const now=new Date(),diff=Math.floor((now-date)/1000);if(diff<60)return 'gerade eben';if(diff<3600)return Math.floor(diff/60)+' Min';if(diff<86400)return Math.floor(diff/3600)+' Std';return date.toLocaleDateString('de-DE')+' '+date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});}
function toggleNotificationDropdown(){const dd=document.getElementById('notificationDropdown'),isOpen=dd.style.display!=='none';document.getElementById('updateDropdownMenu').style.display='none';dd.style.display=isOpen?'none':'block';if(!isOpen){unreadCount=0;updateNotificationBadge();}}
function clearNotifications(){notifications=[];unreadCount=0;updateNotificationBadge();renderNotifications();}
document.addEventListener('click',e=>{const nb=document.getElementById('notificationBtn'),nd=document.getElementById('notificationDropdown');if(nd&&nd.style.display!=='none'&&!nb.contains(e.target)&&!nd.contains(e.target)){nd.style.display='none';}});
async function detailDeactivate(){if(!currentDetailShop)return;if(!confirm('Shop '+currentDetailShop+' deaktivieren?'))return;const fd=new FormData();fd.append('domain',currentDetailShop);fd.append('restart_fpm',document.getElementById('autoFpmRestart').checked?'true':'false');toast('Deaktiviere...','info');await fetch('/api/shops/deactivate',{method:'POST',body:fd});closeModal('detailModal');}
function toggleRateLimitForm(){const area=document.getElementById('rateLimitFormArea');area.style.display=area.style.display==='none'?'block':'none';}
async function applyRateLimit(){if(!currentDetailShop)return;const rateLimit=document.getElementById('detailRateLimitInput').value;const banDuration=document.getElementById('detailBanDurationInput').value;const restartFpm=document.getElementById('autoFpmRestart').checked?'true':'false';if(!confirm('Rate-Limit aktivieren: '+rateLimit+' Req/min?'))return;const s=shops[currentDetailShop];toast('Wechsle zu Rate-Limit...','info');if(s&&s.status==='active'){const dfd=new FormData();dfd.append('domain',currentDetailShop);dfd.append('restart_fpm',restartFpm);await fetch('/api/shops/deactivate',{method:'POST',body:dfd});await new Promise(r=>setTimeout(r,500));}const fd=new FormData();fd.append('domain',currentDetailShop);fd.append('bot_mode','true');fd.append('bot_rate_limit',rateLimit);fd.append('bot_ban_duration',banDuration);fd.append('country_mode','false');fd.append('monitor_only','false');fd.append('restart_fpm',restartFpm);await fetch('/api/shops/activate',{method:'POST',body:fd});closeModal('detailModal');}
async function detailSwitchMode(mode){if(!currentDetailShop)return;const s=shops[currentDetailShop];const restartFpm=document.getElementById('autoFpmRestart').checked?'true':'false';const modeNames={'bot-monitor':'🔍 Monitor','country-dach':'🇩🇪 DACH','country-eu':'🇪🇺 EU+'};if(!confirm('Modus wechseln zu '+modeNames[mode]+'?'))return;toast('Wechsle Modus...','info');if(s&&s.status==='active'){const dfd=new FormData();dfd.append('domain',currentDetailShop);dfd.append('restart_fpm',restartFpm);await fetch('/api/shops/deactivate',{method:'POST',body:dfd});await new Promise(r=>setTimeout(r,500));}const fd=new FormData();fd.append('domain',currentDetailShop);fd.append('restart_fpm',restartFpm);if(mode==='bot-monitor'){fd.append('monitor_only','true');}else if(mode==='country-dach'){fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','true');fd.append('country_rate_limit','100');fd.append('country_ban_duration','600');fd.append('unlimited_countries','de,at,ch');}else if(mode==='country-eu'){fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','true');fd.append('country_rate_limit','100');fd.append('country_ban_duration','600');fd.append('unlimited_countries','de,at,ch,be,cy,ee,es,fi,fr,gb,gr,hr,ie,it,lt,lu,lv,mt,nl,pt,si,sk');}await fetch('/api/shops/activate',{method:'POST',body:fd});closeModal('detailModal');}
function toggleUpdateDropdown(){const menu=document.getElementById('updateDropdownMenu');menu.style.display=menu.style.display==='none'?'block':'none';const cnt=Object.values(agents).filter(a=>a.status==='online').length;document.getElementById('agentCountDropdown').textContent=cnt;}
document.addEventListener('click',e=>{const dd=document.querySelector('.update-dropdown');if(dd&&!dd.contains(e.target)){document.getElementById('updateDropdownMenu').style.display='none';}});
async function updateDashboard(){document.getElementById('updateDropdownMenu').style.display='none';if(!confirm('Dashboard aktualisieren? Das Dashboard wird nach dem Update neu gestartet.'))return;toast('Dashboard wird aktualisiert...','info');try{const r=await fetch('/api/update-dashboard',{method:'POST'});const d=await r.json();if(d.success){toast(d.message,'success');setTimeout(()=>location.reload(),3000);}else{toast(d.error,'error');}}catch(e){toast('Update fehlgeschlagen: '+e,'error');}}
async function updateAgents(){document.getElementById('updateDropdownMenu').style.display='none';const cnt=Object.values(agents).filter(a=>a.status==='online').length;if(cnt===0){toast('Keine Agents online','warning');return;}if(!confirm(cnt+' Agent(s) aktualisieren? Die Agents werden nach dem Update neu gestartet.'))return;toast('Agents werden aktualisiert...','info');try{const r=await fetch('/api/update-agents',{method:'POST'});const d=await r.json();if(d.success){toast(d.message,'success');}else{toast(d.error,'error');}}catch(e){toast('Update fehlgeschlagen: '+e,'error');}}
// Server Detail Modal Functions
function openServerDetailModal(agentId){currentDetailServer=agentId;const a=agents[agentId];if(!a)return;renderServerDetail();document.getElementById('serverDetailModal').classList.add('open');startServerHistoryUpdate();}
function getServerShops(agentId){return Object.values(shops).filter(s=>s.agent_id===agentId);}
function aggregateServerStats(serverShops){const stats={total:serverShops.length,active:0,botMode:0,countryMode:0,monitorMode:0,humanRpm:0,botRpm:0,activeBans:0,totalBans:0,link11:0,direct:0,topBots:{},topCountries:{},bannedItems:[]};serverShops.forEach(s=>{if(s.status==='active')stats.active++;if(s.bot_mode&&!s.monitor_only)stats.botMode++;if(s.country_mode&&!s.monitor_only)stats.countryMode++;if(s.monitor_only)stats.monitorMode++;if(s.link11)stats.link11++;else stats.direct++;const st=s.stats||{};stats.humanRpm+=(st.human_rpm||0);stats.botRpm+=(st.bot_rpm||0);stats.activeBans+=(st.active_bot_bans||0)+(st.active_country_bans||0);stats.totalBans+=(st.bot_bans||0)+(st.country_bans||0);Object.entries(st.top_bots||{}).forEach(([bot,cnt])=>{stats.topBots[bot]=(stats.topBots[bot]||0)+cnt;});Object.entries(st.top_countries||{}).forEach(([country,cnt])=>{stats.topCountries[country]=(stats.topCountries[country]||0)+cnt;});(st.banned_bots||[]).forEach(b=>{if(!stats.bannedItems.includes(b))stats.bannedItems.push(b);});(st.banned_countries||[]).forEach(c=>{if(!stats.bannedItems.includes(c))stats.bannedItems.push(c);});});return stats;}
function renderServerDetail(){if(!currentDetailServer)return;const a=agents[currentDetailServer];if(!a)return;const serverShops=getServerShops(currentDetailServer);const stats=aggregateServerStats(serverShops);document.getElementById('serverDetailHostname').textContent=a.hostname;document.getElementById('serverDetailStatus').innerHTML=a.status==='online'?'<span style="color:var(--success)">🟢 Online</span>':'<span style="color:var(--danger)">🔴 Offline</span>';document.getElementById('serverDetailLoad').textContent=a.load_1m!=null?a.load_1m.toFixed(2):'-';document.getElementById('serverDetailMemory').textContent=a.memory_percent!=null?a.memory_percent.toFixed(1)+'%':'-';document.getElementById('serverDetailShops').textContent=stats.active+'/'+stats.total;document.getElementById('serverDetailBotMode').textContent=stats.botMode;document.getElementById('serverDetailCountryMode').textContent=stats.countryMode;document.getElementById('serverDetailMonitor').textContent=stats.monitorMode;document.getElementById('serverDetailHumanRpm').textContent=stats.humanRpm.toFixed(1);document.getElementById('serverDetailBotRpm').textContent=stats.botRpm.toFixed(1);document.getElementById('serverDetailActiveBans').textContent=stats.activeBans;document.getElementById('serverDetailTotalBans').textContent=stats.totalBans;document.getElementById('serverDetailLink11').textContent=stats.link11;document.getElementById('serverDetailDirect').textContent=stats.direct;document.getElementById('serverDetailTopBots').innerHTML=Object.entries(stats.topBots).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([n,c])=>'<div class="bot-item"><span>'+n+'</span><span>'+c+'</span></div>').join('')||'<div style="color:var(--text-secondary);padding:8px">Keine Daten</div>';document.getElementById('serverDetailTopCountries').innerHTML=Object.entries(stats.topCountries).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([n,c])=>'<div class="bot-item"><span>'+n.toUpperCase()+'</span><span>'+c+'</span></div>').join('')||'<div style="color:var(--text-secondary);padding:8px">Keine Daten</div>';document.getElementById('serverDetailBannedBots').innerHTML=stats.bannedItems.length>0?stats.bannedItems.map(n=>'<div class="bot-item"><span>🚫 '+n+'</span></div>').join(''):'<div style="color:var(--text-secondary);padding:8px">Keine aktiven Bans</div>';document.getElementById('serverShopsTable').innerHTML=serverShops.sort((a,b)=>(b.stats?.req_per_min||0)-(a.stats?.req_per_min||0)).map(s=>{const modeStr=s.monitor_only?'🔍':((s.bot_mode?'🤖':'')+(s.country_mode?'🌍':''))||'-';const bans=(s.stats?.active_bot_bans||0)+(s.stats?.active_country_bans||0);return '<tr style="cursor:pointer" onclick="closeModal(\\'serverDetailModal\\');openDetailModal(\\''+s.domain+'\\')"><td><span class="status-badge status-'+(s.status||'inactive')+'">'+(s.status==='active'?'':'')+'</span></td><td>'+s.domain+'</td><td>'+modeStr+'</td><td>'+(s.stats?.req_per_min||0).toFixed(1)+'</td><td>'+bans+'</td></tr>';}).join('')||'<tr><td colspan="5" style="color:var(--text-secondary);text-align:center;padding:16px">Keine Shops</td></tr>';updateServerCharts(serverShops);}
function updateServerCharts(serverShops){const botData={},countryData={};serverShops.forEach(s=>{const st=s.stats||{};Object.entries(st.top_bots||{}).forEach(([bot,cnt])=>{botData[bot]=(botData[bot]||0)+cnt;});Object.entries(st.top_countries||{}).forEach(([country,cnt])=>{countryData[country]=(countryData[country]||0)+cnt;});});const now=new Date().toISOString().substring(0,19).replace('T',' ');Object.entries(botData).forEach(([bot,cnt])=>{if(!serverBotHistory[bot])serverBotHistory[bot]=[];serverBotHistory[bot].push({timestamp:now,count:cnt});if(serverBotHistory[bot].length>60)serverBotHistory[bot].shift();});Object.entries(countryData).forEach(([country,cnt])=>{if(!serverCountryHistory[country])serverCountryHistory[country]=[];serverCountryHistory[country].push({timestamp:now,count:cnt});if(serverCountryHistory[country].length>60)serverCountryHistory[country].shift();});renderServerBotChart();renderServerCountryChart();}
function renderServerBotChart(){const cv=document.getElementById('serverRequestChart'),ctx=cv.getContext('2d'),ct=cv.parentElement,w=ct.clientWidth-32,h=230;cv.width=w;cv.height=h;ctx.clearRect(0,0,w,h);const bn=Object.keys(serverBotHistory).sort((a,b)=>(serverBotHistory[b][serverBotHistory[b].length-1]?.count||0)-(serverBotHistory[a][serverBotHistory[a].length-1]?.count||0)).slice(0,10);if(bn.length===0){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Noch keine Bot-Daten',w/2-60,h/2);return;}let ts=new Set();bn.forEach(b=>serverBotHistory[b].forEach(p=>ts.add(p.timestamp)));ts=[...ts].sort();if(ts.length<2){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Warte auf Daten...',w/2-50,h/2);return;}let mx=1;bn.forEach(b=>serverBotHistory[b].forEach(p=>{if(p.count>mx)mx=p.count;}));const pd={t:20,r:20,b:40,l:50},cW=w-pd.l-pd.r,cH=h-pd.t-pd.b;ctx.strokeStyle='rgba(255,255,255,0.1)';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pd.t+(cH/4)*i;ctx.beginPath();ctx.moveTo(pd.l,y);ctx.lineTo(w-pd.r,y);ctx.stroke();ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';ctx.fillText(Math.round(mx-(mx/4)*i),5,y+4);}const step=Math.max(1,Math.floor(ts.length/6));ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';for(let i=0;i<ts.length;i+=step){const t=ts[i].split(' ')[1]?.substring(0,5)||ts[i],x=pd.l+(cW/(ts.length-1))*i;ctx.fillText(t,x-15,h-10);}const lg=document.getElementById('serverChartLegend');lg.innerHTML='';bn.forEach((bot,idx)=>{const c=BOT_COLORS[idx%BOT_COLORS.length],pts=serverBotHistory[bot];ctx.strokeStyle=c;ctx.lineWidth=2;ctx.beginPath();pts.forEach((p,i)=>{const ti=ts.indexOf(p.timestamp),x=pd.l+(cW/(ts.length-1))*ti,y=pd.t+cH-(p.count/mx)*cH;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});ctx.stroke();lg.innerHTML+='<div class="legend-item"><div class="legend-color" style="background:'+c+'"></div><span>'+bot+'</span></div>';});}
function renderServerCountryChart(){const cv=document.getElementById('serverCountryChart'),ctx=cv.getContext('2d'),ct=cv.parentElement,w=ct.clientWidth-32,h=230;cv.width=w;cv.height=h;ctx.clearRect(0,0,w,h);const cn=Object.keys(serverCountryHistory).sort((a,b)=>(serverCountryHistory[b][serverCountryHistory[b].length-1]?.count||0)-(serverCountryHistory[a][serverCountryHistory[a].length-1]?.count||0)).slice(0,10);if(cn.length===0){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Noch keine Country-Daten',w/2-70,h/2);return;}let ts=new Set();cn.forEach(c=>serverCountryHistory[c].forEach(p=>ts.add(p.timestamp)));ts=[...ts].sort();if(ts.length<2){ctx.fillStyle='#a0a0b0';ctx.font='14px sans-serif';ctx.fillText('Warte auf Daten...',w/2-50,h/2);return;}let mx=1;cn.forEach(c=>serverCountryHistory[c].forEach(p=>{if(p.count>mx)mx=p.count;}));const pd={t:20,r:20,b:40,l:50},cW=w-pd.l-pd.r,cH=h-pd.t-pd.b;ctx.strokeStyle='rgba(255,255,255,0.1)';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pd.t+(cH/4)*i;ctx.beginPath();ctx.moveTo(pd.l,y);ctx.lineTo(w-pd.r,y);ctx.stroke();ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';ctx.fillText(Math.round(mx-(mx/4)*i),5,y+4);}const step=Math.max(1,Math.floor(ts.length/6));ctx.fillStyle='#a0a0b0';ctx.font='10px sans-serif';for(let i=0;i<ts.length;i+=step){const t=ts[i].split(' ')[1]?.substring(0,5)||ts[i],x=pd.l+(cW/(ts.length-1))*i;ctx.fillText(t,x-15,h-10);}const lg=document.getElementById('serverCountryChartLegend');lg.innerHTML='';cn.forEach((country,idx)=>{const clr=BOT_COLORS[idx%BOT_COLORS.length],pts=serverCountryHistory[country];ctx.strokeStyle=clr;ctx.lineWidth=2;ctx.beginPath();pts.forEach((p,i)=>{const ti=ts.indexOf(p.timestamp),x=pd.l+(cW/(ts.length-1))*ti,y=pd.t+cH-(p.count/mx)*cH;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});ctx.stroke();lg.innerHTML+='<div class="legend-item"><div class="legend-color" style="background:'+clr+'"></div><span>'+country.toUpperCase()+'</span></div>';});}
let serverUpdateInterval=null;
function startServerHistoryUpdate(){if(serverUpdateInterval)clearInterval(serverUpdateInterval);serverBotHistory={};serverCountryHistory={};serverUpdateInterval=setInterval(()=>{if(!currentDetailServer||!document.getElementById('serverDetailModal').classList.contains('open')){clearInterval(serverUpdateInterval);serverUpdateInterval=null;return;}renderServerDetail();},updateInterval);}
async function serverActivateAll(){if(!currentDetailServer)return;const serverShops=getServerShops(currentDetailServer);const inactiveShops=serverShops.filter(s=>s.status!=='active');if(inactiveShops.length===0){toast('Alle Shops sind bereits aktiv','info');return;}if(!confirm(inactiveShops.length+' inaktive Shops auf diesem Server mit Bot-Mode aktivieren?'))return;toast('Aktiviere '+inactiveShops.length+' Shops...','info');const restartFpm=document.getElementById('autoFpmRestart').checked?'true':'false';for(const s of inactiveShops){const fd=new FormData();fd.append('domain',s.domain);fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','false');fd.append('monitor_only','false');fd.append('restart_fpm',restartFpm);await fetch('/api/shops/activate',{method:'POST',body:fd});await new Promise(r=>setTimeout(r,200));}toast('Alle Shops aktiviert','success');}
async function serverDeactivateAll(){if(!currentDetailServer)return;const serverShops=getServerShops(currentDetailServer);const activeShops=serverShops.filter(s=>s.status==='active');if(activeShops.length===0){toast('Keine aktiven Shops','info');return;}if(!confirm(activeShops.length+' aktive Shops auf diesem Server deaktivieren?'))return;toast('Deaktiviere '+activeShops.length+' Shops...','info');const restartFpm=document.getElementById('autoFpmRestart').checked?'true':'false';for(const s of activeShops){const fd=new FormData();fd.append('domain',s.domain);fd.append('restart_fpm',restartFpm);await fetch('/api/shops/deactivate',{method:'POST',body:fd});await new Promise(r=>setTimeout(r,200));}toast('Alle Shops deaktiviert','success');}
async function serverActivateInactive(mode){if(!currentDetailServer)return;const serverShops=getServerShops(currentDetailServer);const inactiveShops=serverShops.filter(s=>s.status!=='active');if(inactiveShops.length===0){toast('Keine inaktiven Shops','info');return;}const modeNames={bot:'🤖 Bot-Mode',monitor:'🔍 Monitor',dach:'🇩🇪🇦🇹🇨🇭 DACH',eu:'🇪🇺 EU+'};if(!confirm(inactiveShops.length+' inaktive Shops mit '+modeNames[mode]+' aktivieren?'))return;toast('Aktiviere '+inactiveShops.length+' Shops...','info');const restartFpm=document.getElementById('autoFpmRestart').checked?'true':'false';for(const s of inactiveShops){const fd=new FormData();fd.append('domain',s.domain);fd.append('restart_fpm',restartFpm);if(mode==='bot'){fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','false');fd.append('monitor_only','false');}else if(mode==='monitor'){fd.append('monitor_only','true');}else if(mode==='dach'){fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','true');fd.append('country_rate_limit','100');fd.append('country_ban_duration','600');fd.append('unlimited_countries','de,at,ch');}else if(mode==='eu'){fd.append('bot_mode','true');fd.append('bot_rate_limit','30');fd.append('bot_ban_duration','300');fd.append('country_mode','true');fd.append('country_rate_limit','100');fd.append('country_ban_duration','600');fd.append('unlimited_countries','de,at,ch,be,cy,ee,es,fi,fr,gb,gr,hr,ie,it,lt,lu,lv,mt,nl,pt,si,sk');}await fetch('/api/shops/activate',{method:'POST',body:fd});await new Promise(r=>setTimeout(r,200));}toast('Shops aktiviert','success');}
connect();
setUpdateInterval(10000);
</script>
</body>
</html>'''
def create_systemd_service():
service = """[Unit]
Description=JTL-WAFi Dashboard v3.0 (WebSocket)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/jtl-wafi/dashboard.py
Restart=always
RestartSec=10
User=root
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
"""
with open("/etc/systemd/system/jtl-wafi.service", 'w') as f:
f.write(service)
print("✅ Service erstellt")
print(" systemctl daemon-reload && systemctl enable --now jtl-wafi")
def main():
import argparse
parser = argparse.ArgumentParser(description=f"JTL-WAFi Dashboard v{VERSION}")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--no-ssl", action="store_true")
parser.add_argument("--install-service", action="store_true")
args = parser.parse_args()
if args.install_service:
create_systemd_service()
return
os.makedirs(DATA_DIR, exist_ok=True)
print("=" * 60)
print(f"🌍 JTL-WAFi Dashboard v{VERSION} (In-Memory)")
print("=" * 60)
print(f"Host: {args.host}:{args.port}")
print(f"SSL: {'Nein' if args.no_ssl else 'Ja'}")
print("=" * 60)
ssl_config = {}
if not args.no_ssl:
generate_ssl_certificate()
ssl_config = {"ssl_certfile": SSL_CERT, "ssl_keyfile": SSL_KEY}
uvicorn.run(app, host=args.host, port=args.port, **ssl_config, log_level="info")
if __name__ == "__main__":
main()