1830 lines
146 KiB
Python
1830 lines
146 KiB
Python
#!/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() |