Admin: Passwortgeschütztes Statistik-Dashboard implementiert

- Neue Routen: /login, /stats, /logout
- Session-basierte Authentifizierung
- Umfassende Statistiken: Seitenaufrufe, Suchvorgänge, Quellen
- Environment-Variablen: ADMIN_PASSWORD, FLASK_SECRET_KEY
- Docker-Integration mit docker-compose.yml
- Responsive UI mit Charts und Aktivitätsprotokoll
This commit is contained in:
2025-08-20 09:11:53 +02:00
parent fcbcb07e76
commit d730e6b266
5 changed files with 525 additions and 2 deletions

View File

@@ -40,6 +40,18 @@ wordle-helper/
## Für Sysadmins (Betrieb) ## Für Sysadmins (Betrieb)
### Environment-Variablen
Für das Admin-Dashboard müssen folgende Variablen gesetzt werden:
```bash
# Flask Secret Key (für Sessions)
export FLASK_SECRET_KEY="your-super-secret-key-change-this-in-production"
# Admin-Passwort für das Statistik-Dashboard
export ADMIN_PASSWORD="your-secure-admin-password"
```
### Docker (empfohlen) ### Docker (empfohlen)
- Build: - Build:
@@ -55,6 +67,7 @@ docker run --rm -p 8000:8000 wordle-cheater
``` ```
- HealthCheck (lokal): `http://localhost:8000/` - HealthCheck (lokal): `http://localhost:8000/`
- AdminDashboard: `http://localhost:8000/stats` (passwortgeschützt)
Hinweise: Hinweise:

122
app.py
View File

@@ -4,12 +4,16 @@ import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Tuple, Dict, List from typing import Tuple, Dict, List
from flask import Flask, render_template, request, send_from_directory from flask import Flask, render_template, request, send_from_directory, session, redirect, url_for, flash
from functools import wraps
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
# Admin-Passwort aus Environment-Variable
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123')
# Logging konfigurieren # Logging konfigurieren
import os
# Logs-Verzeichnis erstellen, falls es nicht existiert # Logs-Verzeichnis erstellen, falls es nicht existiert
logs_dir = Path(__file__).parent / "logs" logs_dir = Path(__file__).parent / "logs"
@@ -48,6 +52,84 @@ def log_search_query(search_params: dict, user_agent: str = None):
logger.info(f"SEARCH: pos='{pos_str}' includes='{includes}' excludes='{excludes}' sources={sources} | User-Agent: {user_agent_clean}") logger.info(f"SEARCH: pos='{pos_str}' includes='{includes}' excludes='{excludes}' sources={sources} | User-Agent: {user_agent_clean}")
def login_required(f):
"""Decorator für passwortgeschützte Routen"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def get_statistics():
"""Liest und analysiert die Log-Dateien für Statistiken"""
stats = {
'total_page_views': 0,
'total_searches': 0,
'page_views_by_page': {},
'searches_by_source': {'OT': 0, 'WF': 0, 'Both': 0},
'recent_activity': [],
'top_search_patterns': {}
}
try:
# Aktuelle Log-Datei lesen
log_file = logs_dir / 'app.log'
if log_file.exists():
with open(log_file, 'r', encoding='utf-8') as f:
for line in f:
if 'PAGE_VIEW:' in line:
stats['total_page_views'] += 1
# Seite extrahieren
if 'PAGE_VIEW: ' in line:
page = line.split('PAGE_VIEW: ')[1].split(' |')[0]
stats['page_views_by_page'][page] = stats['page_views_by_page'].get(page, 0) + 1
elif 'SEARCH:' in line:
stats['total_searches'] += 1
# Quellen extrahieren
if 'sources=[' in line:
sources_part = line.split('sources=')[1].split(']')[0]
if 'OT' in sources_part and 'WF' in sources_part:
stats['searches_by_source']['Both'] += 1
elif 'OT' in sources_part:
stats['searches_by_source']['OT'] += 1
elif 'WF' in sources_part:
stats['searches_by_source']['WF'] += 1
# Suchmuster extrahieren
if 'pos=' in line:
pos_part = line.split('pos=\'')[1].split('\'')[0]
if pos_part:
stats['top_search_patterns'][pos_part] = stats['top_search_patterns'].get(pos_part, 0) + 1
# Letzte 10 Aktivitäten
if len(stats['recent_activity']) < 10:
timestamp = line.split(' - ')[0] if ' - ' in line else ''
if timestamp:
stats['recent_activity'].append({
'timestamp': timestamp,
'line': line.strip()
})
# Backup-Dateien auch durchsuchen
for backup_file in logs_dir.glob("app_*.log.gz"):
try:
import gzip
with gzip.open(backup_file, 'rt', encoding='utf-8') as f:
for line in f:
if 'PAGE_VIEW:' in line:
stats['total_page_views'] += 1
elif 'SEARCH:' in line:
stats['total_searches'] += 1
except Exception as e:
logger.error(f"Fehler beim Lesen der Backup-Datei {backup_file}: {e}")
except Exception as e:
logger.error(f"Fehler beim Lesen der Statistiken: {e}")
return stats
def cleanup_old_logs(): def cleanup_old_logs():
"""Bereinigt Log-Dateien älter als 7 Tage""" """Bereinigt Log-Dateien älter als 7 Tage"""
try: try:
@@ -201,5 +283,41 @@ def service_worker():
return send_from_directory(Path(__file__).parent / 'static', 'sw.js', mimetype='application/javascript') return send_from_directory(Path(__file__).parent / 'static', 'sw.js', mimetype='application/javascript')
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login-Seite für das Admin-Dashboard"""
if request.method == 'POST':
password = request.form.get('password')
logger.info(f"Login-Versuch: Passwort-Länge: {len(password) if password else 0}, ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}")
if password == ADMIN_PASSWORD:
session['logged_in'] = True
flash('Erfolgreich angemeldet!', 'success')
logger.info("Login erfolgreich")
return redirect(url_for('stats'))
else:
flash('Falsches Passwort!', 'error')
logger.warning(f"Login fehlgeschlagen - eingegebenes Passwort: '{password}', erwartetes: '{ADMIN_PASSWORD}'")
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout-Funktion"""
session.pop('logged_in', None)
flash('Erfolgreich abgemeldet!', 'success')
return redirect(url_for('index'))
@app.route('/stats')
@login_required
def stats():
"""Statistik-Dashboard (passwortgeschützt)"""
log_page_view("stats", request.headers.get('User-Agent'))
statistics = get_statistics()
return render_template('stats.html', stats=statistics)
if __name__ == "__main__": if __name__ == "__main__":
logger.info(f"App gestartet - ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}, Länge: {len(ADMIN_PASSWORD) if ADMIN_PASSWORD else 0}")
app.run(debug=True) app.run(debug=True)

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
wordle-helper:
build: .
ports:
- "8000:8000"
volumes:
# Logs-Verzeichnis als Volume mounten
- ./logs:/app/logs
# Optional: Statische Dateien für Entwicklung
- ./static:/app/static:ro
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-your-secret-key-change-this}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
restart: unless-stopped
# Container läuft als nicht-root User für bessere Sicherheit
user: "1000:1000"
# Healthcheck für Container-Status
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
# Explizite Volume-Definition für Logs
logs:
driver: local

117
templates/login.html Normal file
View File

@@ -0,0 +1,117 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin Login - WordleCheater</title>
<meta name="robots" content="noindex,nofollow" />
<style>
:root { --bg:#ffffff; --text:#111827; --muted:#6b7280; --border:#e5e7eb; --button-bg:#111827; --button-text:#ffffff; --error:#b91c1c; --success:#065f46; }
[data-theme="dark"] { --bg:#0b1220; --text:#e5e7eb; --muted:#9ca3af; --border:#334155; --button-bg:#e5e7eb; --button-text:#111827; --error:#ef4444; --success:#10b981; }
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; height: 100vh; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; display: flex; align-items: center; justify-content: center; }
.login-container {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 2rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
max-width: 400px;
width: 90%;
}
.login-header { text-align: center; margin-bottom: 2rem; }
.login-header h1 { margin: 0; color: var(--text); }
.login-header p { color: var(--muted); margin: 0.5rem 0 0 0; }
.form-group { margin-bottom: 1.5rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 600; color: var(--text); }
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.submit-btn {
width: 100%;
padding: 0.75rem;
background: var(--button-bg);
color: var(--button-text);
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover { opacity: 0.9; }
.submit-btn:active { transform: translateY(1px); }
.flash-messages { margin-bottom: 1.5rem; }
.flash {
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.flash.error { background: var(--error); color: white; }
.flash.success { background: var(--success); color: white; }
.back-link {
text-align: center;
margin-top: 1.5rem;
}
.back-link a {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.back-link a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>🔐 Admin Login</h1>
<p>Statistik-Dashboard</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label for="password">Passwort:</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="submit-btn">Anmelden</button>
</form>
<div class="back-link">
<a href="{{ url_for('index') }}">← Zurück zur Hauptseite</a>
</div>
</div>
</body>
</html>

244
templates/stats.html Normal file
View File

@@ -0,0 +1,244 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Statistiken - WordleCheater</title>
<meta name="robots" content="noindex,nofollow" />
<style>
:root { --bg:#ffffff; --text:#111827; --muted:#6b7280; --border:#e5e7eb; --button-bg:#111827; --button-text:#ffffff; --accent:#3b82f6; --success:#10b981; --warning:#f59e0b; }
[data-theme="dark"] { --bg:#0b1220; --text:#e5e7eb; --muted:#9ca3af; --border:#334155; --button-bg:#e5e7eb; --button-text:#111827; --accent:#60a5fa; --success:#34d399; --warning:#fbbf24; }
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; line-height: 1.6; }
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.header h1 { margin: 0; color: var(--text); }
.header .actions { display: flex; gap: 1rem; }
.btn {
padding: 0.5rem 1rem;
background: var(--button-bg);
color: var(--button-text);
border: none;
border-radius: 0.375rem;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
}
.btn:hover { opacity: 0.9; }
.btn.secondary { background: var(--muted); }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card h3 { margin: 0 0 1rem 0; color: var(--text); font-size: 1.1rem; }
.stat-number { font-size: 2.5rem; font-weight: bold; color: var(--accent); margin-bottom: 0.5rem; }
.stat-description { color: var(--muted); font-size: 0.9rem; }
.chart-container {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.chart-container h3 { margin: 0 0 1rem 0; color: var(--text); }
.bar-chart { display: flex; align-items: end; gap: 0.5rem; height: 200px; }
.bar {
background: var(--accent);
min-width: 40px;
border-radius: 0.25rem 0.25rem 0 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.bar-label {
position: absolute;
bottom: -25px;
font-size: 0.8rem;
color: var(--muted);
transform: rotate(-45deg);
transform-origin: top left;
}
.bar-value {
position: absolute;
top: -25px;
font-size: 0.8rem;
color: var(--text);
font-weight: 600;
}
.recent-activity {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
}
.recent-activity h3 { margin: 0 0 1rem 0; color: var(--text); }
.activity-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-family: monospace;
font-size: 0.85rem;
}
.activity-item:last-child { border-bottom: none; }
.activity-timestamp { color: var(--accent); font-weight: 600; }
.search-patterns {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
}
.search-patterns h3 { margin: 0 0 1rem 0; color: var(--text); }
.pattern-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.pattern-item:last-child { border-bottom: none; }
.pattern-text { font-family: monospace; color: var(--text); }
.pattern-count { color: var(--accent); font-weight: 600; }
.no-data {
text-align: center;
color: var(--muted);
padding: 2rem;
font-style: italic;
}
@media (max-width: 768px) {
.container { padding: 1rem; }
.header { flex-direction: column; gap: 1rem; align-items: stretch; }
.stats-grid { grid-template-columns: 1fr; }
.bar-chart { height: 150px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Statistik-Dashboard</h1>
<div class="actions">
<a href="{{ url_for('index') }}" class="btn secondary">← Hauptseite</a>
<a href="{{ url_for('logout') }}" class="btn">Abmelden</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>📈 Gesamte Seitenaufrufe</h3>
<div class="stat-number">{{ stats.total_page_views }}</div>
<div class="stat-description">Alle protokollierten Seitenaufrufe</div>
</div>
<div class="stat-card">
<h3>🔍 Gesamte Suchvorgänge</h3>
<div class="stat-number">{{ stats.total_searches }}</div>
<div class="stat-description">Alle durchgeführten Wortsuche</div>
</div>
<div class="stat-card">
<h3>📱 Seitenaufrufe pro Seite</h3>
{% if stats.page_views_by_page %}
{% for page, count in stats.page_views_by_page.items() %}
<div style="margin-bottom: 0.5rem;">
<strong>{{ page }}:</strong> {{ count }}
</div>
{% endfor %}
{% else %}
<div class="no-data">Keine Daten verfügbar</div>
{% endif %}
</div>
</div>
<div class="chart-container">
<h3>🔍 Suchvorgänge nach Quellen</h3>
{% if stats.searches_by_source and (stats.searches_by_source.OT > 0 or stats.searches_by_source.WF > 0 or stats.searches_by_source.Both > 0) %}
<div class="bar-chart">
{% set max_count = [stats.searches_by_source.OT, stats.searches_by_source.WF, stats.searches_by_source.Both] | max %}
{% if stats.searches_by_source.OT > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.OT / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.OT }}</div>
<div class="bar-label">OpenThesaurus</div>
</div>
{% endif %}
{% if stats.searches_by_source.WF > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.WF / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.WF }}</div>
<div class="bar-label">Wordfreq</div>
</div>
{% endif %}
{% if stats.searches_by_source.Both > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.Both / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.Both }}</div>
<div class="bar-label">Beide</div>
</div>
{% endif %}
</div>
{% else %}
<div class="no-data">Keine Suchdaten verfügbar</div>
{% endif %}
</div>
{% if stats.top_search_patterns %}
<div class="search-patterns">
<h3>🎯 Häufigste Suchmuster (Positionen)</h3>
{% for pattern, count in stats.top_search_patterns.items() | sort(attribute='1', reverse=true) %}
<div class="pattern-item">
<span class="pattern-text">{{ pattern or '(leer)' }}</span>
<span class="pattern-count">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div class="recent-activity">
<h3>⏰ Letzte Aktivitäten</h3>
{% if stats.recent_activity %}
{% for activity in stats.recent_activity %}
<div class="activity-item">
<span class="activity-timestamp">{{ activity.timestamp }}</span>
<span>{{ activity.line.split(' - ', 2)[2] if ' - ' in activity.line else activity.line }}</span>
</div>
{% endfor %}
{% else %}
<div class="no-data">Keine Aktivitäten verfügbar</div>
{% endif %}
</div>
</div>
</body>
</html>