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:
13
README.md
13
README.md
@@ -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
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Health‑Check (lokal): `http://localhost:8000/`
|
- Health‑Check (lokal): `http://localhost:8000/`
|
||||||
|
- Admin‑Dashboard: `http://localhost:8000/stats` (passwortgeschützt)
|
||||||
|
|
||||||
Hinweise:
|
Hinweise:
|
||||||
|
|
||||||
|
122
app.py
122
app.py
@@ -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
31
docker-compose.yml
Normal 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
117
templates/login.html
Normal 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 - Wordle‑Cheater</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
244
templates/stats.html
Normal 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 - Wordle‑Cheater</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>
|
Reference in New Issue
Block a user