new file: .gitignore
new file: README.md new file: database/schema.sql new file: paper-plugin/pom.xml new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java new file: paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java new file: paper-plugin/src/main/resources/config.yml new file: paper-plugin/src/main/resources/plugin.yml new file: paper-plugin/target/classes/config.yml new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class new file: paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class new file: paper-plugin/target/classes/plugin.yml new file: paper-plugin/target/maven-archiver/pom.properties new file: paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file: paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file: paper-plugin/target/mclogger-paper-1.0.0.jar new file: paper-plugin/target/original-mclogger-paper-1.0.0.jar new file: velocity-plugin/pom.xml new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java new file: velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java new file: velocity-plugin/src/main/resources/velocity-config.yml new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class new file: velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class new file: velocity-plugin/target/classes/velocity-config.yml new file: velocity-plugin/target/classes/velocity-plugin.json new file: velocity-plugin/target/maven-archiver/pom.properties new file: velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file: velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file: velocity-plugin/target/mclogger-velocity-1.0.0.jar new file: velocity-plugin/target/original-mclogger-velocity-1.0.0.jar new file: web/Dockerfile new file: web/app.py new file: web/blueprints/__init__.py new file: web/blueprints/auth.py new file: web/blueprints/group_admin.py new file: web/blueprints/panel.py new file: web/blueprints/site_admin.py new file: web/config.py new file: web/crypto.py new file: web/docker-compose.yml new file: web/panel_db.py new file: web/requirements.txt new file: web/static/css/style.css new file: web/static/js/main.js new file: web/templates/_pagination.html new file: web/templates/admin/base.html new file: web/templates/admin/dashboard.html new file: web/templates/admin/group_edit.html new file: web/templates/admin/group_members.html new file: web/templates/admin/groups.html new file: web/templates/admin/user_edit.html new file: web/templates/admin/users.html new file: web/templates/auth/admin_login.html new file: web/templates/auth/login.html new file: web/templates/base.html new file: web/templates/blocks.html new file: web/templates/chat.html new file: web/templates/commands.html new file: web/templates/dashboard.html new file: web/templates/deaths.html new file: web/templates/group_admin/base.html new file: web/templates/group_admin/dashboard.html new file: web/templates/group_admin/database.html new file: web/templates/group_admin/member_edit.html new file: web/templates/group_admin/members.html new file: web/templates/login.html new file: web/templates/panel/blocks.html new file: web/templates/panel/chat.html new file: web/templates/panel/commands.html new file: web/templates/panel/dashboard.html new file: web/templates/panel/deaths.html new file: web/templates/panel/no_db.html new file: web/templates/panel/perms.html new file: web/templates/panel/player_detail.html new file: web/templates/panel/players.html new file: web/templates/panel/proxy.html new file: web/templates/panel/server_events.html new file: web/templates/panel/sessions.html new file: web/templates/perms.html new file: web/templates/player_detail.html new file: web/templates/players.html new file: web/templates/proxy.html new file: web/templates/server_events.html new file: web/templates/sessions.html
This commit is contained in:
24
web/Dockerfile
Normal file
24
web/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# MCLogger Web Panel
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
FLASK_APP=app.py
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "app:app"]
|
||||
|
||||
76
web/app.py
Normal file
76
web/app.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
MCLogger – Flask Web-Panel
|
||||
Multi-Tenant mit Gruppen, Rollen & verschlüsselten DB-Zugangsdaten.
|
||||
Coolify-kompatibel: alle Einstellungen via ENV.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from flask import Flask, session
|
||||
from config import Config
|
||||
from panel_db import init_databases, get_user_groups
|
||||
|
||||
from blueprints.auth import auth
|
||||
from blueprints.site_admin import site_admin
|
||||
from blueprints.group_admin import group_admin
|
||||
from blueprints.panel import panel
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = Config.SECRET_KEY
|
||||
|
||||
# Blueprints registrieren
|
||||
app.register_blueprint(auth)
|
||||
app.register_blueprint(site_admin)
|
||||
app.register_blueprint(group_admin)
|
||||
app.register_blueprint(panel)
|
||||
|
||||
# Panel-Datenbank-Tabellen anlegen
|
||||
try:
|
||||
init_databases()
|
||||
except Exception as e:
|
||||
app.logger.warning(f"DB-Initialisierung fehlgeschlagen (noch nicht konfiguriert?): {e}")
|
||||
|
||||
# ── Template-Filter ───────────────────────────────────────
|
||||
|
||||
@app.template_filter("fmt_duration")
|
||||
def fmt_duration(seconds):
|
||||
if seconds is None:
|
||||
return "—"
|
||||
seconds = int(seconds)
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h: return f"{h}h {m}m"
|
||||
elif m: return f"{m}m {s}s"
|
||||
return f"{s}s"
|
||||
|
||||
@app.template_filter("fmt_dt")
|
||||
def fmt_dt(dt):
|
||||
if dt is None:
|
||||
return "—"
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
uid = session.get("user_id")
|
||||
try:
|
||||
groups = get_user_groups(uid) if uid else []
|
||||
except Exception:
|
||||
groups = []
|
||||
return {
|
||||
"now": datetime.now(),
|
||||
"app_version": "2.0.0",
|
||||
"author": "SimolZimol",
|
||||
"user_groups": groups,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG)
|
||||
|
||||
1
web/blueprints/__init__.py
Normal file
1
web/blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# blueprints/__init__.py
|
||||
93
web/blueprints/auth.py
Normal file
93
web/blueprints/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
MCLogger – Authentifizierung
|
||||
Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins.
|
||||
"""
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from panel_db import check_login, get_user_groups
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
flash("Bitte nutze den Site-Admin-Login.", "warning")
|
||||
return redirect(url_for("auth.admin_login"))
|
||||
if user:
|
||||
groups = get_user_groups(user["id"])
|
||||
if not groups:
|
||||
error = "Du bist keiner Gruppe zugewiesen. Wende dich an einen Admin."
|
||||
else:
|
||||
_set_user_session(user, groups)
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
else:
|
||||
error = "Falscher Benutzername oder Passwort."
|
||||
return render_template("auth/login.html", error=error)
|
||||
|
||||
|
||||
@auth.route("/admin/login", methods=["GET", "POST"])
|
||||
def admin_login():
|
||||
if session.get("is_site_admin"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_site_admin"] = True
|
||||
session["group_id"] = None
|
||||
session["permissions"] = {}
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
elif user:
|
||||
error = "Keine Site-Admin-Berechtigung."
|
||||
else:
|
||||
error = "Falscher Benutzername oder Passwort."
|
||||
return render_template("auth/admin_login.html", error=error)
|
||||
|
||||
|
||||
@auth.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth.route("/switch-group/<int:group_id>")
|
||||
def switch_group(group_id):
|
||||
if not session.get("user_id") or session.get("is_site_admin"):
|
||||
return redirect(url_for("auth.login"))
|
||||
user_id = session["user_id"]
|
||||
groups = get_user_groups(user_id)
|
||||
target = next((g for g in groups if g["id"] == group_id), None)
|
||||
if not target:
|
||||
flash("Gruppe nicht gefunden oder kein Zugriff.", "danger")
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
_apply_group(target)
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
|
||||
|
||||
def _set_user_session(user, groups):
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_site_admin"] = False
|
||||
_apply_group(groups[0]) # Erste Gruppe als Standard
|
||||
|
||||
|
||||
def _apply_group(group):
|
||||
raw = group.get("permissions")
|
||||
if isinstance(raw, str):
|
||||
perms = json.loads(raw)
|
||||
elif isinstance(raw, dict):
|
||||
perms = raw
|
||||
else:
|
||||
perms = {}
|
||||
session["group_id"] = group["id"]
|
||||
session["group_name"] = group["name"]
|
||||
session["role"] = group.get("role", "member")
|
||||
session["permissions"] = perms
|
||||
164
web/blueprints/group_admin.py
Normal file
164
web/blueprints/group_admin.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
MCLogger – Gruppen-Admin-Bereich
|
||||
Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten.
|
||||
"""
|
||||
import json
|
||||
from functools import wraps
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
import panel_db as db
|
||||
|
||||
group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin")
|
||||
|
||||
ALL_PERMISSIONS = [
|
||||
("view_dashboard", "Dashboard"),
|
||||
("view_players", "Spieler"),
|
||||
("view_sessions", "Sessions"),
|
||||
("view_chat", "Chat"),
|
||||
("view_commands", "Commands"),
|
||||
("view_deaths", "Tode"),
|
||||
("view_blocks", "Block-Events"),
|
||||
("view_proxy", "Proxy-Events"),
|
||||
("view_server_events", "Server-Events"),
|
||||
("view_perms", "Berechtigungen"),
|
||||
]
|
||||
|
||||
|
||||
def group_admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("user_id"):
|
||||
return redirect(url_for("auth.login"))
|
||||
if session.get("is_site_admin"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
if session.get("role") != "admin":
|
||||
flash("Du hast keine Gruppen-Admin-Berechtigung.", "danger")
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@group_admin.route("/")
|
||||
@group_admin_required
|
||||
def dashboard():
|
||||
group_id = session["group_id"]
|
||||
group = db.get_group_by_id(group_id)
|
||||
members = db.get_group_members(group_id)
|
||||
has_db = db.has_db_configured(group_id)
|
||||
return render_template("group_admin/dashboard.html",
|
||||
group=group, members=members, has_db=has_db)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Mitglieder
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@group_admin.route("/members")
|
||||
@group_admin_required
|
||||
def members():
|
||||
group_id = session["group_id"]
|
||||
group = db.get_group_by_id(group_id)
|
||||
members = db.get_group_members(group_id)
|
||||
all_users = db.list_all_users()
|
||||
member_ids = {m["id"] for m in members}
|
||||
non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]]
|
||||
return render_template("group_admin/members.html",
|
||||
group=group, members=members, non_members=non_members,
|
||||
all_permissions=ALL_PERMISSIONS)
|
||||
|
||||
|
||||
@group_admin.route("/members/add", methods=["POST"])
|
||||
@group_admin_required
|
||||
def member_add():
|
||||
group_id = session["group_id"]
|
||||
user_id = request.form.get("user_id", type=int)
|
||||
role = request.form.get("role", "member")
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
flash("Mitglied hinzugefügt.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
|
||||
@group_admin.route("/members/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@group_admin_required
|
||||
def member_edit(user_id):
|
||||
group_id = session["group_id"]
|
||||
group = db.get_group_by_id(group_id)
|
||||
member = db.get_group_member(user_id, group_id)
|
||||
user = db.get_user_by_id(user_id)
|
||||
if not member or not user:
|
||||
flash("Mitglied nicht gefunden.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
raw_perms = member.get("permissions")
|
||||
current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {})
|
||||
|
||||
if request.method == "POST":
|
||||
role = request.form.get("role", "member")
|
||||
new_perms = {key: (request.form.get(key) == "1") for key, _ in ALL_PERMISSIONS}
|
||||
db.update_member(user_id, group_id, role, new_perms)
|
||||
flash("Berechtigungen aktualisiert.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
return render_template("group_admin/member_edit.html",
|
||||
group=group, user=user, member=member,
|
||||
current_perms=current_perms, all_permissions=ALL_PERMISSIONS)
|
||||
|
||||
|
||||
@group_admin.route("/members/<int:user_id>/remove", methods=["POST"])
|
||||
@group_admin_required
|
||||
def member_remove(user_id):
|
||||
if user_id == session["user_id"]:
|
||||
flash("Du kannst dich nicht selbst entfernen.", "danger")
|
||||
else:
|
||||
db.remove_group_member(user_id, session["group_id"])
|
||||
flash("Mitglied entfernt.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Datenbank-Konfiguration
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@group_admin.route("/database", methods=["GET", "POST"])
|
||||
@group_admin_required
|
||||
def database():
|
||||
group_id = session["group_id"]
|
||||
group = db.get_group_by_id(group_id)
|
||||
has_db = db.has_db_configured(group_id)
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
host = request.form.get("host", "").strip()
|
||||
port = request.form.get("port", "3306").strip()
|
||||
user = request.form.get("user", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
database_name = request.form.get("database", "").strip()
|
||||
|
||||
if not all([host, port, user, database_name]):
|
||||
error = "Host, Port, Benutzer und Datenbankname sind Pflichtfelder."
|
||||
else:
|
||||
try:
|
||||
# Verbindung testen
|
||||
import pymysql
|
||||
test = pymysql.connect(
|
||||
host=host, port=int(port), user=user,
|
||||
password=password, database=database_name,
|
||||
connect_timeout=5
|
||||
)
|
||||
test.close()
|
||||
db.set_group_db_creds(group_id, host, int(port), user, password, database_name)
|
||||
flash("Datenbankverbindung gespeichert und getestet ✓", "success")
|
||||
return redirect(url_for("group_admin.database"))
|
||||
except Exception as e:
|
||||
error = f"Verbindungstest fehlgeschlagen: {e}"
|
||||
|
||||
return render_template("group_admin/database.html",
|
||||
group=group, has_db=has_db, error=error)
|
||||
|
||||
|
||||
@group_admin.route("/database/delete", methods=["POST"])
|
||||
@group_admin_required
|
||||
def database_delete():
|
||||
db.delete_group_db_creds(session["group_id"])
|
||||
flash("Datenbankverbindung entfernt.", "success")
|
||||
return redirect(url_for("group_admin.database"))
|
||||
410
web/blueprints/panel.py
Normal file
410
web/blueprints/panel.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
MCLogger – Panel (MC-Daten)
|
||||
Zeigt die Minecraft-Logdaten der Gruppe an.
|
||||
Die Datenbankverbindung kommt aus den verschlüsselten Gruppen-Credentials.
|
||||
"""
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
import panel_db as pdb
|
||||
|
||||
panel = Blueprint("panel", __name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Hilfsfunktionen
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("user_id"):
|
||||
return redirect(url_for("auth.login"))
|
||||
if session.get("is_site_admin") and not session.get("group_id"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
if not session.get("group_id"):
|
||||
return redirect(url_for("auth.login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def perm_required(perm):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if session.get("is_site_admin") or session.get("role") == "admin":
|
||||
return f(*args, **kwargs)
|
||||
perms = session.get("permissions", {})
|
||||
if not perms.get(perm, False):
|
||||
flash("Du hast keine Berechtigung, diese Seite zu sehen.", "danger")
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def get_mc_db():
|
||||
"""Liefert eine Datenbankverbindung zur MC-Datenbank der aktuellen Gruppe."""
|
||||
group_id = session.get("group_id")
|
||||
if not group_id:
|
||||
abort(403)
|
||||
creds = pdb.get_group_db_creds(group_id)
|
||||
if not creds:
|
||||
abort(503)
|
||||
return pymysql.connect(
|
||||
host=creds["host"],
|
||||
port=creds["port"],
|
||||
user=creds["user"],
|
||||
password=creds["password"],
|
||||
database=creds["database"],
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=True,
|
||||
connect_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def query(sql, args=None, fetchone=False):
|
||||
conn = get_mc_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, args or ())
|
||||
return cur.fetchone() if fetchone else cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_paged(sql, count_sql, args=None, page=1, per_page=50):
|
||||
args = args or ()
|
||||
total_row = query(count_sql, args, fetchone=True)
|
||||
total = list(total_row.values())[0] if total_row else 0
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
offset = (page - 1) * per_page
|
||||
rows = query(sql + f" LIMIT {per_page} OFFSET {offset}", args)
|
||||
return rows, total, pages
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Fehler-Handler wenn DB nicht konfiguriert
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.errorhandler(503)
|
||||
def no_db(e):
|
||||
return render_template("panel/no_db.html"), 503
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Dashboard
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/")
|
||||
@login_required
|
||||
@perm_required("view_dashboard")
|
||||
def dashboard():
|
||||
group_id = session["group_id"]
|
||||
if not pdb.has_db_configured(group_id):
|
||||
return render_template("panel/no_db.html")
|
||||
try:
|
||||
stats = {
|
||||
"players_total": query("SELECT COUNT(*) AS c FROM players", fetchone=True)["c"],
|
||||
"sessions_today": query("SELECT COUNT(*) AS c FROM player_sessions WHERE login_time >= CURDATE()", fetchone=True)["c"],
|
||||
"chat_today": query("SELECT COUNT(*) AS c FROM player_chat WHERE timestamp >= CURDATE()", fetchone=True)["c"],
|
||||
"commands_today": query("SELECT COUNT(*) AS c FROM player_commands WHERE timestamp >= CURDATE()", fetchone=True)["c"],
|
||||
"blocks_today": query("SELECT COUNT(*) AS c FROM block_events WHERE timestamp >= CURDATE()", fetchone=True)["c"],
|
||||
"deaths_today": query("SELECT COUNT(*) AS c FROM player_deaths WHERE timestamp >= CURDATE()", fetchone=True)["c"],
|
||||
"proxy_events_today": query("SELECT COUNT(*) AS c FROM proxy_events WHERE timestamp >= CURDATE()", fetchone=True)["c"],
|
||||
}
|
||||
online = query("""
|
||||
SELECT p.username, ps.server_name, ps.login_time, ps.country
|
||||
FROM player_sessions ps
|
||||
JOIN players p ON p.uuid = ps.player_uuid
|
||||
WHERE ps.logout_time IS NULL
|
||||
ORDER BY ps.login_time DESC
|
||||
""")
|
||||
top_players = query("""
|
||||
SELECT username, total_playtime_sec
|
||||
FROM players ORDER BY total_playtime_sec DESC LIMIT 10
|
||||
""")
|
||||
death_causes = query("""
|
||||
SELECT cause, COUNT(*) AS cnt FROM player_deaths
|
||||
WHERE timestamp >= NOW() - INTERVAL 7 DAY
|
||||
GROUP BY cause ORDER BY cnt DESC LIMIT 8
|
||||
""")
|
||||
server_events = query("""
|
||||
SELECT timestamp, event_type, server_name, message
|
||||
FROM server_events
|
||||
WHERE timestamp >= NOW() - INTERVAL 24 HOUR
|
||||
ORDER BY timestamp DESC LIMIT 20
|
||||
""")
|
||||
except Exception as e:
|
||||
flash(f"Datenbankfehler: {e}", "danger")
|
||||
return render_template("panel/no_db.html")
|
||||
|
||||
return render_template("panel/dashboard.html",
|
||||
stats=stats, online=online, top_players=top_players,
|
||||
death_causes=death_causes, server_events=server_events)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Spieler
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/players")
|
||||
@login_required
|
||||
@perm_required("view_players")
|
||||
def players():
|
||||
search = request.args.get("q", "")
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
if search:
|
||||
base = "FROM players WHERE username LIKE %s"
|
||||
args = (f"%{search}%",)
|
||||
else:
|
||||
base = "FROM players WHERE 1"
|
||||
args = ()
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * {base} ORDER BY last_seen DESC",
|
||||
f"SELECT COUNT(*) AS c {base}", args, page)
|
||||
return render_template("panel/players.html",
|
||||
players=rows, total=total, pages=pages, page=page, search=search)
|
||||
|
||||
|
||||
@panel.route("/players/<uuid>")
|
||||
@login_required
|
||||
@perm_required("view_players")
|
||||
def player_detail(uuid):
|
||||
player = query("SELECT * FROM players WHERE uuid = %s", (uuid,), fetchone=True)
|
||||
if not player:
|
||||
flash("Spieler nicht gefunden.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
perms = session.get("permissions", {})
|
||||
is_admin = session.get("is_site_admin") or session.get("role") == "admin"
|
||||
return render_template("panel/player_detail.html",
|
||||
player=player,
|
||||
sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)),
|
||||
chat = query("SELECT * FROM player_chat WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_chat")) else [],
|
||||
commands = query("SELECT * FROM player_commands WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_commands")) else [],
|
||||
deaths = query("SELECT * FROM player_deaths WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)) if (is_admin or perms.get("view_deaths")) else [],
|
||||
teleports = query("SELECT * FROM player_teleports WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)),
|
||||
stats = query("SELECT * FROM player_stats WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)),
|
||||
proxy_events = query("SELECT * FROM proxy_events WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)) if (is_admin or perms.get("view_proxy")) else [],
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Sessions
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/sessions")
|
||||
@login_required
|
||||
@perm_required("view_sessions")
|
||||
def sessions():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", "")
|
||||
server = request.args.get("server", "")
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM player_sessions {where} ORDER BY login_time DESC",
|
||||
f"SELECT COUNT(*) AS c FROM player_sessions {where}", tuple(args), page)
|
||||
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_sessions ORDER BY server_name")]
|
||||
return render_template("panel/sessions.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
player=player, server=server, servers=servers)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Chat
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/chat")
|
||||
@login_required
|
||||
@perm_required("view_chat")
|
||||
def chat():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
search = request.args.get("q", ""); server = request.args.get("server", "")
|
||||
date_from = request.args.get("from", ""); date_to = request.args.get("to", "")
|
||||
conditions, args = [], []
|
||||
if search: conditions.append("message LIKE %s"); args.append(f"%{search}%")
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
if date_from: conditions.append("timestamp >= %s"); args.append(date_from)
|
||||
if date_to: conditions.append("timestamp <= %s"); args.append(date_to + " 23:59:59")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM player_chat {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM player_chat {where}", tuple(args), page)
|
||||
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_chat ORDER BY server_name")]
|
||||
return render_template("panel/chat.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
search=search, server=server, servers=servers, date_from=date_from, date_to=date_to)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Commands
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/commands")
|
||||
@login_required
|
||||
@perm_required("view_commands")
|
||||
def commands():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); search = request.args.get("q", ""); server = request.args.get("server", "")
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if search: conditions.append("command LIKE %s"); args.append(f"%{search}%")
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM player_commands {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM player_commands {where}", tuple(args), page)
|
||||
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_commands ORDER BY server_name")]
|
||||
return render_template("panel/commands.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
player=player, search=search, server=server, servers=servers)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Tode
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/deaths")
|
||||
@login_required
|
||||
@perm_required("view_deaths")
|
||||
def deaths():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); cause = request.args.get("cause", "")
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if cause: conditions.append("cause = %s"); args.append(cause)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM player_deaths {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM player_deaths {where}", tuple(args), page)
|
||||
causes = [r["cause"] for r in query("SELECT DISTINCT cause FROM player_deaths ORDER BY cause")]
|
||||
return render_template("panel/deaths.html",
|
||||
rows=rows, total=total, pages=pages, page=page, player=player, cause=cause, causes=causes)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Block-Events
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/blocks")
|
||||
@login_required
|
||||
@perm_required("view_blocks")
|
||||
def blocks():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
event_type = request.args.get("type", ""); player = request.args.get("player", "")
|
||||
world = request.args.get("world", ""); server = request.args.get("server", ""); block = request.args.get("block", "")
|
||||
conditions, args = [], []
|
||||
if event_type: conditions.append("event_type = %s"); args.append(event_type)
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if world: conditions.append("world = %s"); args.append(world)
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
if block: conditions.append("block_type LIKE %s"); args.append(f"%{block}%")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM block_events {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM block_events {where}", tuple(args), page)
|
||||
worlds = [r["world"] for r in query("SELECT DISTINCT world FROM block_events ORDER BY world")]
|
||||
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM block_events ORDER BY server_name")]
|
||||
return render_template("panel/blocks.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
event_type=event_type, player=player, world=world, server=server, block=block,
|
||||
worlds=worlds, servers=servers)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Proxy-Events
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/proxy")
|
||||
@login_required
|
||||
@perm_required("view_proxy")
|
||||
def proxy():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
event_type = request.args.get("type", ""); player = request.args.get("player", "")
|
||||
conditions, args = [], []
|
||||
if event_type: conditions.append("event_type = %s"); args.append(event_type)
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM proxy_events {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM proxy_events {where}", tuple(args), page)
|
||||
return render_template("panel/proxy.html",
|
||||
rows=rows, total=total, pages=pages, page=page, event_type=event_type, player=player)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Server-Events
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/server-events")
|
||||
@login_required
|
||||
@perm_required("view_server_events")
|
||||
def server_events():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
server = request.args.get("server", ""); etype = request.args.get("type", "")
|
||||
conditions, args = [], []
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
if etype: conditions.append("event_type = %s"); args.append(etype)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM server_events {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM server_events {where}", tuple(args), page)
|
||||
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM server_events ORDER BY server_name")]
|
||||
etypes = [r["event_type"] for r in query("SELECT DISTINCT event_type FROM server_events ORDER BY event_type")]
|
||||
return render_template("panel/server_events.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
server=server, etype=etype, servers=servers, etypes=etypes)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Berechtigungen (plugin_events)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/perms")
|
||||
@login_required
|
||||
@perm_required("view_perms")
|
||||
def perms():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); plugin_filter = request.args.get("plugin", ""); etype = request.args.get("type", "")
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if plugin_filter: conditions.append("plugin_name = %s"); args.append(plugin_filter)
|
||||
if etype: conditions.append("event_type LIKE %s"); args.append(f"%{etype}%")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows, total, pages = query_paged(
|
||||
f"SELECT * FROM plugin_events {where} ORDER BY timestamp DESC",
|
||||
f"SELECT COUNT(*) AS c FROM plugin_events {where}", tuple(args), page)
|
||||
plugins = [r["plugin_name"] for r in query("SELECT DISTINCT plugin_name FROM plugin_events ORDER BY plugin_name")]
|
||||
return render_template("panel/perms.html",
|
||||
rows=rows, total=total, pages=pages, page=page,
|
||||
player=player, plugin_filter=plugin_filter, etype=etype, plugins=plugins)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# API
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@panel.route("/api/online")
|
||||
@login_required
|
||||
def api_online():
|
||||
rows = query("""
|
||||
SELECT p.username, ps.server_name, ps.login_time, ps.country
|
||||
FROM player_sessions ps
|
||||
JOIN players p ON p.uuid = ps.player_uuid
|
||||
WHERE ps.logout_time IS NULL ORDER BY ps.login_time DESC
|
||||
""")
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
@panel.route("/api/stats")
|
||||
@login_required
|
||||
def api_stats():
|
||||
return jsonify({
|
||||
"players_online": query("SELECT COUNT(*) AS c FROM player_sessions WHERE logout_time IS NULL", fetchone=True)["c"],
|
||||
})
|
||||
221
web/blueprints/site_admin.py
Normal file
221
web/blueprints/site_admin.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
MCLogger – Site-Admin-Bereich
|
||||
Verwaltet alle Gruppen und Nutzer global.
|
||||
"""
|
||||
from functools import wraps
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
import panel_db as db
|
||||
|
||||
site_admin = Blueprint("site_admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("is_site_admin"):
|
||||
return redirect(url_for("auth.admin_login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Dashboard
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/")
|
||||
@admin_required
|
||||
def dashboard():
|
||||
groups = db.list_all_groups()
|
||||
users = db.list_all_users()
|
||||
# Für jede Gruppe DB-Status prüfen
|
||||
for g in groups:
|
||||
g["has_db"] = db.has_db_configured(g["id"])
|
||||
return render_template("admin/dashboard.html", groups=groups, users=users)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Gruppen verwalten
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/groups")
|
||||
@admin_required
|
||||
def groups():
|
||||
all_groups = db.list_all_groups()
|
||||
for g in all_groups:
|
||||
g["has_db"] = db.has_db_configured(g["id"])
|
||||
return render_template("admin/groups.html", groups=all_groups)
|
||||
|
||||
|
||||
@site_admin.route("/groups/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def group_new():
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
desc = request.form.get("description", "").strip()
|
||||
if not name:
|
||||
flash("Gruppenname darf nicht leer sein.", "danger")
|
||||
elif db.get_group_by_name(name):
|
||||
flash("Eine Gruppe mit diesem Namen existiert bereits.", "danger")
|
||||
else:
|
||||
db.create_group(name, desc)
|
||||
flash(f"Gruppe '{name}' erstellt.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=None)
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def group_edit(group_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
if not group:
|
||||
flash("Gruppe nicht gefunden.", "danger")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
desc = request.form.get("description", "").strip()
|
||||
if not name:
|
||||
flash("Gruppenname darf nicht leer sein.", "danger")
|
||||
else:
|
||||
db.update_group(group_id, name, desc)
|
||||
flash("Gruppe aktualisiert.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=group)
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def group_delete(group_id):
|
||||
db.delete_group(group_id)
|
||||
flash("Gruppe gelöscht.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/members")
|
||||
@admin_required
|
||||
def group_members(group_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
members = db.get_group_members(group_id)
|
||||
all_users = db.list_all_users()
|
||||
member_ids = {m["id"] for m in members}
|
||||
non_members = [u for u in all_users if u["id"] not in member_ids]
|
||||
return render_template("admin/group_members.html",
|
||||
group=group, members=members, non_members=non_members)
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/members/add", methods=["POST"])
|
||||
@admin_required
|
||||
def group_member_add(group_id):
|
||||
user_id = request.form.get("user_id", type=int)
|
||||
role = request.form.get("role", "member")
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
flash("Mitglied hinzugefügt.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@admin_required
|
||||
def group_member_remove(group_id, user_id):
|
||||
db.remove_group_member(user_id, group_id)
|
||||
flash("Mitglied entfernt.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Nutzer verwalten
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/users")
|
||||
@admin_required
|
||||
def users():
|
||||
return render_template("admin/users.html", users=db.list_all_users())
|
||||
|
||||
|
||||
@site_admin.route("/users/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def user_new():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
is_site_admin = request.form.get("is_site_admin") == "1"
|
||||
if not username or not email or not password:
|
||||
flash("Alle Felder sind Pflichtfelder.", "danger")
|
||||
elif db.get_user_by_username(username):
|
||||
flash("Benutzername bereits vergeben.", "danger")
|
||||
else:
|
||||
db.create_user(username, email, password, is_site_admin)
|
||||
flash(f"Nutzer '{username}' erstellt.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=None)
|
||||
|
||||
|
||||
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def user_edit(user_id):
|
||||
user = db.get_user_by_id(user_id)
|
||||
if not user:
|
||||
flash("Nutzer nicht gefunden.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip()
|
||||
is_site_admin = request.form.get("is_site_admin") == "1"
|
||||
new_password = request.form.get("new_password", "")
|
||||
db.update_user(user_id, username, email, is_site_admin)
|
||||
if new_password:
|
||||
db.change_password(user_id, new_password)
|
||||
flash("Passwort geändert.", "info")
|
||||
flash("Nutzer aktualisiert.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=user)
|
||||
|
||||
|
||||
@site_admin.route("/users/<int:user_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def user_delete(user_id):
|
||||
if user_id == session.get("user_id"):
|
||||
flash("Du kannst dich nicht selbst löschen.", "danger")
|
||||
else:
|
||||
db.delete_user(user_id)
|
||||
flash("Nutzer gelöscht.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Als Gruppe anzeigen (Site-Admin liest Gruppen-DB)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/view-group/<int:group_id>")
|
||||
@admin_required
|
||||
def view_group(group_id):
|
||||
"""Site-Admin wechselt temporär in eine Gruppe, um deren MC-Daten zu sehen."""
|
||||
group = db.get_group_by_id(group_id)
|
||||
if not group:
|
||||
flash("Gruppe nicht gefunden.", "danger")
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
if not db.has_db_configured(group_id):
|
||||
flash("Für diese Gruppe ist noch keine Datenbank konfiguriert.", "warning")
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
# Alle Berechtigungen als Admin
|
||||
all_perms = {k: True for k in ["view_dashboard","view_players","view_sessions",
|
||||
"view_chat","view_commands","view_deaths","view_blocks",
|
||||
"view_proxy","view_server_events","view_perms"]}
|
||||
session["group_id"] = group_id
|
||||
session["group_name"] = group["name"]
|
||||
session["role"] = "admin"
|
||||
session["permissions"] = all_perms
|
||||
session["admin_viewing"] = True
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
|
||||
|
||||
@site_admin.route("/stop-view")
|
||||
@admin_required
|
||||
def stop_view():
|
||||
"""Kehrt zum Site-Admin-Dashboard zurück."""
|
||||
session.pop("group_id", None)
|
||||
session.pop("group_name", None)
|
||||
session.pop("role", None)
|
||||
session.pop("permissions", None)
|
||||
session.pop("admin_viewing", None)
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
46
web/config.py
Normal file
46
web/config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
MCLogger – Konfiguration
|
||||
Alle Einstellungen über ENV-Variablen (Coolify-kompatibel).
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
# ── Flask ──────────────────────────────────────────────────
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-use-a-long-random-string-min-32-chars")
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "5000"))
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
# ── Panel-Datenbank (Nutzer, Gruppen, Mitgliedschaften) ────
|
||||
PANEL_DB_HOST = os.getenv("PANEL_DB_HOST", "localhost")
|
||||
PANEL_DB_PORT = int(os.getenv("PANEL_DB_PORT", "3306"))
|
||||
PANEL_DB_USER = os.getenv("PANEL_DB_USER", "root")
|
||||
PANEL_DB_PASSWORD = os.getenv("PANEL_DB_PASSWORD", "")
|
||||
PANEL_DB_NAME = os.getenv("PANEL_DB_NAME", "mclogger_panel")
|
||||
|
||||
# ── Credentials-Datenbank (verschlüsselte MC-DB-Zugangsdaten) ──
|
||||
CREDS_DB_HOST = os.getenv("CREDS_DB_HOST", os.getenv("PANEL_DB_HOST", "localhost"))
|
||||
CREDS_DB_PORT = int(os.getenv("CREDS_DB_PORT", os.getenv("PANEL_DB_PORT", "3306")))
|
||||
CREDS_DB_USER = os.getenv("CREDS_DB_USER", os.getenv("PANEL_DB_USER", "root"))
|
||||
CREDS_DB_PASSWORD = os.getenv("CREDS_DB_PASSWORD", os.getenv("PANEL_DB_PASSWORD", ""))
|
||||
CREDS_DB_NAME = os.getenv("CREDS_DB_NAME", "mclogger_creds")
|
||||
|
||||
# ── Sicherheit ────────────────────────────────────────────
|
||||
PASSWORD_PEPPER = os.getenv("PASSWORD_PEPPER", "change-me-global-pepper-secret-never-change")
|
||||
# Generieren: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
FERNET_KEY = os.getenv("FERNET_KEY", "")
|
||||
|
||||
# ── Standard-Berechtigungen neuer Gruppenmitglieder ───────
|
||||
DEFAULT_PERMISSIONS = {
|
||||
"view_dashboard": True,
|
||||
"view_players": True,
|
||||
"view_sessions": True,
|
||||
"view_chat": True,
|
||||
"view_commands": True,
|
||||
"view_deaths": True,
|
||||
"view_blocks": True,
|
||||
"view_proxy": False,
|
||||
"view_server_events": False,
|
||||
"view_perms": False,
|
||||
}
|
||||
63
web/crypto.py
Normal file
63
web/crypto.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
MCLogger – Kryptographie-Utilities
|
||||
- Passwort-Hashing: PBKDF2-HMAC-SHA256 mit Salt (pro Nutzer) + Pepper (global, via ENV)
|
||||
- DB-Credential-Verschlüsselung: Fernet (symmetrisch, Schlüssel via ENV)
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from config import Config
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Passwort-Hashing
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_salt() -> str:
|
||||
"""Generiert einen zufälligen 32-Byte Hex-Salt."""
|
||||
return os.urandom(32).hex()
|
||||
|
||||
|
||||
def hash_password(password: str, salt: str) -> str:
|
||||
"""
|
||||
Hasht ein Passwort mit PBKDF2-HMAC-SHA256.
|
||||
Verwendet: salt (pro Nutzer) + pepper (global aus ENV)
|
||||
"""
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
(salt + Config.PASSWORD_PEPPER).encode("utf-8"),
|
||||
iterations=260_000,
|
||||
)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def verify_password(password: str, salt: str, stored_hash: str) -> bool:
|
||||
"""Prüft ob ein Passwort korrekt ist."""
|
||||
return hash_password(password, salt) == stored_hash
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Fernet-Verschlüsselung (für DB-Zugangsdaten)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
key = Config.FERNET_KEY
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"FERNET_KEY ist nicht gesetzt! "
|
||||
"Generieren: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_str(plaintext: str) -> str:
|
||||
"""Verschlüsselt einen String mit Fernet."""
|
||||
return _get_fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_str(ciphertext: str) -> str:
|
||||
"""Entschlüsselt einen Fernet-verschlüsselten String."""
|
||||
return _get_fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8")
|
||||
46
web/docker-compose.yml
Normal file
46
web/docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
mclogger-panel:
|
||||
build: .
|
||||
image: mclogger-panel:latest
|
||||
container_name: mclogger-panel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-5000}:5000"
|
||||
environment:
|
||||
# ── Flask ──────────────────────────────────────────────
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
|
||||
# ── Panel-Datenbank (Benutzer / Gruppen) ───────────────
|
||||
PANEL_DB_HOST: "${PANEL_DB_HOST:-localhost}"
|
||||
PANEL_DB_PORT: "${PANEL_DB_PORT:-3306}"
|
||||
PANEL_DB_USER: "${PANEL_DB_USER:-mclogger_panel}"
|
||||
PANEL_DB_PASSWORD: "${PANEL_DB_PASSWORD}"
|
||||
PANEL_DB_NAME: "${PANEL_DB_NAME:-mclogger_panel}"
|
||||
|
||||
# ── Credentials-Datenbank (verschlüsselte MC-DB-Daten) ─
|
||||
CREDS_DB_HOST: "${CREDS_DB_HOST:-localhost}"
|
||||
CREDS_DB_PORT: "${CREDS_DB_PORT:-3306}"
|
||||
CREDS_DB_USER: "${CREDS_DB_USER:-mclogger_creds}"
|
||||
CREDS_DB_PASSWORD: "${CREDS_DB_PASSWORD}"
|
||||
CREDS_DB_NAME: "${CREDS_DB_NAME:-mclogger_creds}"
|
||||
|
||||
# ── Sicherheit ──────────────────────────────────────────
|
||||
# Fernet-Schlüssel (32 URL-safe base64 bytes):
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
FERNET_KEY: "${FERNET_KEY}"
|
||||
# Pepper für Passwort-Hashing (beliebige lange Zeichenkette)
|
||||
PASSWORD_PEPPER: "${PASSWORD_PEPPER}"
|
||||
|
||||
# ── Server ──────────────────────────────────────────────
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "5000"
|
||||
DEBUG: "${DEBUG:-false}"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/login"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
349
web/panel_db.py
Normal file
349
web/panel_db.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
MCLogger – Panel-Datenbank-Operationen
|
||||
Verwaltet Nutzer, Gruppen, Mitgliedschaften (PANEL_DB)
|
||||
und verschlüsselte MC-DB-Zugangsdaten (CREDS_DB).
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
from config import Config
|
||||
from crypto import generate_salt, hash_password, verify_password, encrypt_str, decrypt_str
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Datenbankverbindungen
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_panel_db():
|
||||
return pymysql.connect(
|
||||
host=Config.PANEL_DB_HOST,
|
||||
port=Config.PANEL_DB_PORT,
|
||||
user=Config.PANEL_DB_USER,
|
||||
password=Config.PANEL_DB_PASSWORD,
|
||||
database=Config.PANEL_DB_NAME,
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def get_creds_db():
|
||||
return pymysql.connect(
|
||||
host=Config.CREDS_DB_HOST,
|
||||
port=Config.CREDS_DB_PORT,
|
||||
user=Config.CREDS_DB_USER,
|
||||
password=Config.CREDS_DB_PASSWORD,
|
||||
database=Config.CREDS_DB_NAME,
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def _panel_query(sql, args=None, fetchone=False, write=False):
|
||||
conn = get_panel_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, args or ())
|
||||
if write:
|
||||
return cur.lastrowid
|
||||
return cur.fetchone() if fetchone else cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _creds_query(sql, args=None, fetchone=False, write=False):
|
||||
conn = get_creds_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, args or ())
|
||||
if write:
|
||||
return cur.lastrowid
|
||||
return cur.fetchone() if fetchone else cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Initialisierung – Tabellen anlegen
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
PANEL_SCHEMA = [
|
||||
"""CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
salt VARCHAR(64) NOT NULL,
|
||||
is_site_admin TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
group_id INT NOT NULL,
|
||||
role ENUM('admin','member') DEFAULT 'member',
|
||||
permissions JSON,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_group (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
|
||||
CREDS_SCHEMA = [
|
||||
"""CREATE TABLE IF NOT EXISTS group_databases (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
group_id INT UNIQUE NOT NULL,
|
||||
enc_host TEXT NOT NULL,
|
||||
enc_port TEXT NOT NULL,
|
||||
enc_user TEXT NOT NULL,
|
||||
enc_password TEXT NOT NULL,
|
||||
enc_database TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
|
||||
|
||||
def init_databases():
|
||||
"""Erstellt alle benötigten Tabellen falls nicht vorhanden."""
|
||||
panel = get_panel_db()
|
||||
try:
|
||||
with panel.cursor() as cur:
|
||||
for stmt in PANEL_SCHEMA:
|
||||
cur.execute(stmt)
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
creds = get_creds_db()
|
||||
try:
|
||||
with creds.cursor() as cur:
|
||||
for stmt in CREDS_SCHEMA:
|
||||
cur.execute(stmt)
|
||||
finally:
|
||||
creds.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Nutzer
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def create_user(username: str, email: str, password: str, is_site_admin: bool = False) -> int:
|
||||
salt = generate_salt()
|
||||
pw_hash = hash_password(password, salt)
|
||||
return _panel_query(
|
||||
"INSERT INTO users (username, email, password_hash, salt, is_site_admin) VALUES (%s,%s,%s,%s,%s)",
|
||||
(username, email, pw_hash, salt, int(is_site_admin)), write=True
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_username(username: str):
|
||||
return _panel_query("SELECT * FROM users WHERE username=%s", (username,), fetchone=True)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int):
|
||||
return _panel_query("SELECT * FROM users WHERE id=%s", (user_id,), fetchone=True)
|
||||
|
||||
|
||||
def update_user(user_id: int, username: str, email: str, is_site_admin: bool):
|
||||
_panel_query(
|
||||
"UPDATE users SET username=%s, email=%s, is_site_admin=%s WHERE id=%s",
|
||||
(username, email, int(is_site_admin), user_id), write=True
|
||||
)
|
||||
|
||||
|
||||
def change_password(user_id: int, new_password: str):
|
||||
salt = generate_salt()
|
||||
pw_hash = hash_password(new_password, salt)
|
||||
_panel_query(
|
||||
"UPDATE users SET password_hash=%s, salt=%s WHERE id=%s",
|
||||
(pw_hash, salt, user_id), write=True
|
||||
)
|
||||
|
||||
|
||||
def delete_user(user_id: int):
|
||||
_panel_query("DELETE FROM users WHERE id=%s", (user_id,), write=True)
|
||||
|
||||
|
||||
def check_login(username: str, password: str):
|
||||
"""Prüft Anmeldedaten. Gibt den Nutzer zurück oder None."""
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user["salt"], user["password_hash"]):
|
||||
return None
|
||||
_panel_query("UPDATE users SET last_login=NOW() WHERE id=%s", (user["id"],), write=True)
|
||||
return user
|
||||
|
||||
|
||||
def list_all_users():
|
||||
return _panel_query(
|
||||
"SELECT u.*, COUNT(gm.group_id) AS group_count "
|
||||
"FROM users u LEFT JOIN group_members gm ON gm.user_id=u.id "
|
||||
"GROUP BY u.id ORDER BY u.username"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Gruppen
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def create_group(name: str, description: str = "") -> int:
|
||||
return _panel_query(
|
||||
"INSERT INTO user_groups (name, description) VALUES (%s,%s)",
|
||||
(name, description), write=True
|
||||
)
|
||||
|
||||
|
||||
def get_group_by_id(group_id: int):
|
||||
return _panel_query("SELECT * FROM user_groups WHERE id=%s", (group_id,), fetchone=True)
|
||||
|
||||
|
||||
def get_group_by_name(name: str):
|
||||
return _panel_query("SELECT * FROM user_groups WHERE name=%s", (name,), fetchone=True)
|
||||
|
||||
|
||||
def update_group(group_id: int, name: str, description: str):
|
||||
_panel_query(
|
||||
"UPDATE user_groups SET name=%s, description=%s WHERE id=%s",
|
||||
(name, description, group_id), write=True
|
||||
)
|
||||
|
||||
|
||||
def delete_group(group_id: int):
|
||||
_panel_query("DELETE FROM user_groups WHERE id=%s", (group_id,), write=True)
|
||||
|
||||
|
||||
def list_all_groups():
|
||||
return _panel_query(
|
||||
"SELECT g.*, COUNT(gm.user_id) AS member_count "
|
||||
"FROM user_groups g LEFT JOIN group_members gm ON gm.group_id=g.id "
|
||||
"GROUP BY g.id ORDER BY g.name"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Gruppenmitgliedschaften
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_user_groups(user_id: int):
|
||||
return _panel_query(
|
||||
"SELECT g.*, gm.role, gm.permissions "
|
||||
"FROM user_groups g "
|
||||
"JOIN group_members gm ON gm.group_id=g.id "
|
||||
"WHERE gm.user_id=%s ORDER BY g.name",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
|
||||
def get_group_member(user_id: int, group_id: int):
|
||||
return _panel_query(
|
||||
"SELECT * FROM group_members WHERE user_id=%s AND group_id=%s",
|
||||
(user_id, group_id), fetchone=True
|
||||
)
|
||||
|
||||
|
||||
def get_group_members(group_id: int):
|
||||
return _panel_query(
|
||||
"SELECT u.id, u.username, u.email, u.last_login, gm.role, gm.permissions, gm.joined_at "
|
||||
"FROM group_members gm "
|
||||
"JOIN users u ON u.id=gm.user_id "
|
||||
"WHERE gm.group_id=%s ORDER BY gm.role DESC, u.username",
|
||||
(group_id,)
|
||||
)
|
||||
|
||||
|
||||
def add_group_member(user_id: int, group_id: int, role: str = "member", permissions: dict = None):
|
||||
if permissions is None:
|
||||
permissions = Config.DEFAULT_PERMISSIONS
|
||||
_panel_query(
|
||||
"INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s) "
|
||||
"ON DUPLICATE KEY UPDATE role=VALUES(role), permissions=VALUES(permissions)",
|
||||
(user_id, group_id, role, json.dumps(permissions)), write=True
|
||||
)
|
||||
|
||||
|
||||
def update_member(user_id: int, group_id: int, role: str, permissions: dict):
|
||||
_panel_query(
|
||||
"UPDATE group_members SET role=%s, permissions=%s WHERE user_id=%s AND group_id=%s",
|
||||
(role, json.dumps(permissions), user_id, group_id), write=True
|
||||
)
|
||||
|
||||
|
||||
def remove_group_member(user_id: int, group_id: int):
|
||||
_panel_query(
|
||||
"DELETE FROM group_members WHERE user_id=%s AND group_id=%s",
|
||||
(user_id, group_id), write=True
|
||||
)
|
||||
|
||||
|
||||
def get_permissions(user_id: int, group_id: int) -> dict:
|
||||
"""Gibt die Berechtigungen des Nutzers in der Gruppe zurück."""
|
||||
member = get_group_member(user_id, group_id)
|
||||
if not member:
|
||||
return {}
|
||||
raw = member.get("permissions")
|
||||
if isinstance(raw, str):
|
||||
return json.loads(raw)
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
return {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# MC-Datenbank-Zugangsdaten (verschlüsselt)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def set_group_db_creds(group_id: int, host: str, port: int, user: str, password: str, database: str):
|
||||
"""Verschlüsselt und speichert die MC-DB-Zugangsdaten einer Gruppe."""
|
||||
_creds_query(
|
||||
"INSERT INTO group_databases (group_id, enc_host, enc_port, enc_user, enc_password, enc_database) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s) "
|
||||
"ON DUPLICATE KEY UPDATE enc_host=VALUES(enc_host), enc_port=VALUES(enc_port), "
|
||||
"enc_user=VALUES(enc_user), enc_password=VALUES(enc_password), enc_database=VALUES(enc_database)",
|
||||
(group_id,
|
||||
encrypt_str(host),
|
||||
encrypt_str(str(port)),
|
||||
encrypt_str(user),
|
||||
encrypt_str(password),
|
||||
encrypt_str(database)),
|
||||
write=True
|
||||
)
|
||||
|
||||
|
||||
def get_group_db_creds(group_id: int) -> dict | None:
|
||||
"""Gibt die entschlüsselten MC-DB-Zugangsdaten zurück oder None."""
|
||||
row = _creds_query(
|
||||
"SELECT * FROM group_databases WHERE group_id=%s",
|
||||
(group_id,), fetchone=True
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"host": decrypt_str(row["enc_host"]),
|
||||
"port": int(decrypt_str(row["enc_port"])),
|
||||
"user": decrypt_str(row["enc_user"]),
|
||||
"password": decrypt_str(row["enc_password"]),
|
||||
"database": decrypt_str(row["enc_database"]),
|
||||
}
|
||||
|
||||
|
||||
def delete_group_db_creds(group_id: int):
|
||||
_creds_query("DELETE FROM group_databases WHERE group_id=%s", (group_id,), write=True)
|
||||
|
||||
|
||||
def has_db_configured(group_id: int) -> bool:
|
||||
row = _creds_query(
|
||||
"SELECT id FROM group_databases WHERE group_id=%s",
|
||||
(group_id,), fetchone=True
|
||||
)
|
||||
return row is not None
|
||||
4
web/requirements.txt
Normal file
4
web/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Flask==3.1.0
|
||||
PyMySQL==1.1.1
|
||||
cryptography==42.0.8
|
||||
gunicorn==22.0.0
|
||||
230
web/static/css/style.css
Normal file
230
web/static/css/style.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/* ============================================================
|
||||
MCLogger – Admin Interface CSS
|
||||
Author: SimolZimol
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--sidebar-width: 230px;
|
||||
--sidebar-bg: #0f1117;
|
||||
--sidebar-border: #1e2230;
|
||||
--topbar-bg: #13161f;
|
||||
--content-bg: #181c27;
|
||||
--card-bg: #1e2230;
|
||||
--card-border: #2a2f42;
|
||||
--text-muted-custom: #6b7280;
|
||||
--accent-green: #1db954;
|
||||
}
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────── */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--content-bg);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
transition: width .25s ease, min-width .25s ease;
|
||||
}
|
||||
|
||||
#sidebar.collapsed {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
#sidebar.collapsed .sidebar-brand div,
|
||||
#sidebar.collapsed .sidebar-brand small,
|
||||
#sidebar.collapsed .nav-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: .25rem 0;
|
||||
}
|
||||
|
||||
#sidebar .nav-link {
|
||||
color: #9ca3af;
|
||||
border-radius: 6px;
|
||||
padding: .45rem .75rem;
|
||||
font-size: .875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#sidebar .nav-link i {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#sidebar .nav-link:hover { background: rgba(255,255,255,.05); color: #e5e7eb; }
|
||||
#sidebar .nav-link.active { background: rgba(29,185,84,.15); color: var(--accent-green); }
|
||||
|
||||
/* ── Topbar ──────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
background: var(--topbar-bg);
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────── */
|
||||
#page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--content-bg);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: rgba(0,0,0,.2);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* ── Statistik-Karten ────────────────────────────────────── */
|
||||
.stat-card { transition: transform .15s; cursor: default; }
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: .72rem;
|
||||
color: var(--text-muted-custom);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Tabellen ────────────────────────────────────────────── */
|
||||
.table {
|
||||
color: #d1d5db;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.table > thead {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .03em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover > td {
|
||||
background: rgba(255,255,255,.04);
|
||||
}
|
||||
|
||||
.table-dark {
|
||||
--bs-table-bg: rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────── */
|
||||
.badge { font-size: .7rem; font-weight: 500; }
|
||||
|
||||
/* ── Inputs ──────────────────────────────────────────────── */
|
||||
.form-control, .form-select {
|
||||
background-color: #111827;
|
||||
border-color: var(--card-border);
|
||||
color: #e5e7eb;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--accent-green);
|
||||
box-shadow: 0 0 0 2px rgba(29,185,84,.25);
|
||||
background-color: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.form-control::placeholder { color: #6b7280; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
.btn-success { background-color: var(--accent-green); border-color: var(--accent-green); }
|
||||
.btn-success:hover { background-color: #17a34a; border-color: #17a34a; }
|
||||
|
||||
/* ── Pagination ──────────────────────────────────────────── */
|
||||
.page-link {
|
||||
background-color: var(--card-bg);
|
||||
border-color: var(--card-border);
|
||||
color: #9ca3af;
|
||||
font-size: .8rem;
|
||||
}
|
||||
.page-link:hover { background-color: rgba(255,255,255,.07); color: #f3f4f6; }
|
||||
.page-item.active .page-link { background-color: var(--accent-green); border-color: var(--accent-green); color: #000; }
|
||||
.page-item.disabled .page-link { background-color: transparent; }
|
||||
|
||||
/* ── Login-Seite ─────────────────────────────────────────── */
|
||||
body .card.shadow-lg {
|
||||
background: #1e2230;
|
||||
border: 1px solid #2a2f42;
|
||||
}
|
||||
|
||||
/* ── Scrollbars ──────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
|
||||
|
||||
/* ── Diverse ─────────────────────────────────────────────── */
|
||||
.blink {
|
||||
animation: blink-anim 1.5s infinite;
|
||||
}
|
||||
@keyframes blink-anim {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: .2; }
|
||||
}
|
||||
|
||||
.font-monospace { font-family: 'Consolas', 'Cascadia Code', monospace !important; }
|
||||
|
||||
.text-truncate { max-width: 250px; }
|
||||
|
||||
/* Chart.js Canvas */
|
||||
canvas { max-height: 250px; }
|
||||
|
||||
/* Alert */
|
||||
.alert { font-size: .85rem; }
|
||||
|
||||
/* sticky-top in dark scrollable containers */
|
||||
.sticky-top { z-index: 1; }
|
||||
97
web/static/js/main.js
Normal file
97
web/static/js/main.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/* ============================================================
|
||||
MCLogger – main.js
|
||||
Author: SimolZimol
|
||||
============================================================ */
|
||||
|
||||
// ── Sidebar Toggle ────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('sidebarToggle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
if (btn && sidebar) {
|
||||
btn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
|
||||
});
|
||||
|
||||
// Zustand beim Laden wiederherstellen
|
||||
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// Online-Count aktualisieren
|
||||
updateOnlineCount();
|
||||
setInterval(updateOnlineCount, 30_000);
|
||||
|
||||
// Tooltips initialisieren
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
|
||||
// Automatisch Tabellen sortieren ermöglichen
|
||||
initTableSort();
|
||||
});
|
||||
|
||||
// ── Online-Count API ──────────────────────────────────────
|
||||
function updateOnlineCount() {
|
||||
fetch('/api/online')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const el = document.getElementById('online-count');
|
||||
if (el) el.textContent = data.length;
|
||||
})
|
||||
.catch(() => {/* Ignorieren wenn nicht eingeloggt */});
|
||||
}
|
||||
|
||||
// ── Einfache Tabellen-Sortierung ──────────────────────────
|
||||
function initTableSort() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => {
|
||||
const table = th.closest('table');
|
||||
const idx = Array.from(th.parentNode.children).indexOf(th);
|
||||
const asc = th.dataset.order !== 'asc';
|
||||
th.dataset.order = asc ? 'asc' : 'desc';
|
||||
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
||||
rows.sort((a, b) => {
|
||||
const av = a.cells[idx]?.textContent.trim() ?? '';
|
||||
const bv = b.cells[idx]?.textContent.trim() ?? '';
|
||||
return asc ? av.localeCompare(bv, 'de', {numeric: true}) : bv.localeCompare(av, 'de', {numeric: true});
|
||||
});
|
||||
const tbody = table.querySelector('tbody');
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Formatierung ──────────────────────────────────────────
|
||||
function fmtDuration(sec) {
|
||||
sec = parseInt(sec) || 0;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ── Live-Chat-Reload (optional) ───────────────────────────
|
||||
if (window.location.pathname === '/chat') {
|
||||
// Kein automatisches Reload im Chat (würde Filter zurücksetzen)
|
||||
}
|
||||
|
||||
// ── Kopieren in Zwischenablage ────────────────────────────
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const target = document.querySelector(btn.dataset.target);
|
||||
if (target) {
|
||||
navigator.clipboard.writeText(target.textContent.trim())
|
||||
.then(() => {
|
||||
btn.innerHTML = '<i class="bi bi-check2"></i>';
|
||||
setTimeout(() => btn.innerHTML = '<i class="bi bi-clipboard"></i>', 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
22
web/templates/_pagination.html
Normal file
22
web/templates/_pagination.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% if pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center flex-wrap">
|
||||
<li class="page-item {{ 'disabled' if page == 1 }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page-1, 1]|max)) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% for p in range([1, page-3]|max, [pages+1, page+4]|min) %}
|
||||
<li class="page-item {{ 'active' if p == page }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=p)) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {{ 'disabled' if page == pages }}">
|
||||
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page+1, pages]|min)) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted small">Page {{ page }} of {{ pages }} · {{ total }} entries</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
43
web/templates/admin/base.html
Normal file
43
web/templates/admin/base.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Site Admin{% endblock %} — MCLogger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-danger">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="{{ url_for('site_admin.dashboard') }}">
|
||||
<i class="bi bi-shield-fill-check me-2"></i>MCLogger — Site Admin
|
||||
</a>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('site_admin.dashboard') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Gruppen</a>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Benutzer</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
121
web/templates/admin/dashboard.html
Normal file
121
web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-shield-fill-check text-danger me-2"></i>Site Admin Dashboard</h2>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-danger">{{ stats.group_count }}</div>
|
||||
<div class="text-muted">Gruppen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-warning">{{ stats.user_count }}</div>
|
||||
<div class="text-muted">Benutzer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-success">{{ stats.db_configured }}</div>
|
||||
<div class="text-muted">DBs konfiguriert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
|
||||
<div class="text-muted">Site Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-collection-fill me-2"></i>Gruppen</span>
|
||||
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg"></i> Neu
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Name</th><th>Mitglieder</th><th>DB</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td>{{ g.name }}</td>
|
||||
<td>{{ g.member_count }}</td>
|
||||
<td>
|
||||
{% if g.has_db %}
|
||||
<span class="badge bg-success">Konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Keine</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Browse">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-muted text-center py-3">Keine Gruppen vorhanden</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="text-muted small">Alle Gruppen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-people-fill me-2"></i>Benutzer</span>
|
||||
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg"></i> Neu
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Gruppen</th><th>Admin</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.group_count }}</td>
|
||||
<td>{% if u.is_site_admin %}<span class="badge bg-danger"><i class="bi bi-shield-fill"></i></span>{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-muted text-center py-3">Keine Benutzer vorhanden</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="text-muted small">Alle Benutzer →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
36
web/templates/admin/group_edit.html
Normal file
36
web/templates/admin/group_edit.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe erstellen' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gruppenname *</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ group.name if group else request.form.get('name', '') }}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ group.description if group else request.form.get('description', '') }}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if group else 'Erstellen' }}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
web/templates/admin/group_members.html
Normal file
86
web/templates/admin/group_members.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Mitglieder – {{ group.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">Mitglieder: <span class="text-success">{{ group.name }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Aktuelle Mitglieder -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-people-fill me-2"></i>Aktuelle Mitglieder ({{ members|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}</td>
|
||||
<td>
|
||||
{% if m.role == 'admin' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Member</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_toggle_role', group_id=group.id, user_id=m.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="Rolle wechseln">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{{ m.username }} aus Gruppe entfernen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer hinzufügen -->
|
||||
<div class="col-md-5">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Benutzer hinzufügen</div>
|
||||
<div class="card-body">
|
||||
{% if non_members %}
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzer auswählen</label>
|
||||
<select name="user_id" class="form-select">
|
||||
{% for u in non_members %}
|
||||
<option value="{{ u.id }}">{{ u.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-3">Alle Benutzer sind bereits Mitglied.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
59
web/templates/admin/groups.html
Normal file
59
web/templates/admin/groups.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Gruppen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-collection-fill me-2"></i>Gruppen</h2>
|
||||
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-lg me-1"></i>Neue Gruppe
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th>DB</th><th>Erstellt</th><th class="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ g.id }}</td>
|
||||
<td class="fw-semibold">{{ g.name }}</td>
|
||||
<td class="text-muted small">{{ g.description or '—' }}</td>
|
||||
<td>{{ g.member_count }}</td>
|
||||
<td>
|
||||
{% if g.has_db %}
|
||||
<span class="badge bg-success"><i class="bi bi-database-fill-check me-1"></i>Konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Keine DB</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ g.created_at | fmt_dt }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Daten browsen">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_members', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Mitglieder">
|
||||
<i class="bi bi-people-fill"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('site_admin.group_delete', group_id=g.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Gruppe {{ g.name }} löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-muted text-center py-4">Noch keine Gruppen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
50
web/templates/admin/user_edit.html
Normal file
50
web/templates/admin/user_edit.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Benutzer bearbeiten: ' ~ user.username if user else 'Neuer Benutzer' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername *</label>
|
||||
<input type="text" name="username" class="form-control" required
|
||||
value="{{ user.username if user else request.form.get('username', '') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ 'Neues Passwort (leer lassen = unverändert)' if user else 'Passwort *' }}</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
{{ '' if user else 'required' }}>
|
||||
{% if not user %}
|
||||
<div class="form-text">Mindestens 8 Zeichen empfohlen.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
||||
value="1" {{ 'checked' if user and user.is_site_admin }}>
|
||||
<label class="form-check-label" for="is_site_admin">
|
||||
<span class="text-danger fw-semibold"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
|
||||
<small class="text-muted d-block">Voller Zugriff auf alle Gruppen und Einstellungen</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if user else 'Erstellen' }}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
53
web/templates/admin/users.html
Normal file
53
web/templates/admin/users.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Benutzer{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-people-fill me-2"></i>Benutzer</h2>
|
||||
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-success">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>Benutzer</th><th>Gruppen</th><th>Site Admin</th><th>Erstellt</th><th class="text-end">Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ u.username }}</td>
|
||||
<td>
|
||||
{% for g in u.groups %}
|
||||
<span class="badge bg-secondary me-1">{{ g.name }}
|
||||
{% if g.role == 'admin' %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
|
||||
</span>
|
||||
{% else %}<span class="text-muted small">Keine</span>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_site_admin %}
|
||||
<span class="badge bg-danger"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ u.created_at | fmt_dt }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('site_admin.user_delete', user_id=u.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Benutzer {{ u.username }} löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-4">Keine Benutzer vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
web/templates/auth/admin_login.html
Normal file
63
web/templates/auth/admin_login.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Site Admin Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-fill-check fs-1 text-danger"></i>
|
||||
<h3 class="fw-bold mt-2">Site Admin</h3>
|
||||
<p class="text-muted small">Administrativer Zugang</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-danger">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-danger"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" required autofocus
|
||||
value="{{ request.form.get('username', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Passwort</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-danger"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="bi bi-shield-lock-fill me-1"></i> Admin Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurück zum normalen Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
63
web/templates/auth/login.html
Normal file
63
web/templates/auth/login.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-database-fill-gear fs-1 text-success"></i>
|
||||
<h3 class="fw-bold mt-2">MCLogger</h3>
|
||||
<p class="text-muted small">Panel Login</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" required autofocus
|
||||
value="{{ request.form.get('username', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Passwort</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Einloggen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.admin_login') }}" class="text-muted small">
|
||||
<i class="bi bi-shield-fill me-1"></i>Site Admin Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
173
web/templates/base.html
Normal file
173
web/templates/base.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MCLogger{% endblock %} — Panel</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex" id="wrapper">
|
||||
<!-- ── Sidebar ─────────────────────────────────────────── -->
|
||||
<nav id="sidebar" class="d-flex flex-column p-3">
|
||||
<div class="sidebar-brand mb-3 text-center">
|
||||
<i class="bi bi-database-fill-gear fs-2 text-success"></i>
|
||||
<div class="fw-bold mt-1">MCLogger</div>
|
||||
{% if session.get('group_name') %}
|
||||
<span class="badge bg-success mt-1">{{ session.group_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set perms = session.get('permissions', {}) %}
|
||||
{% set is_admin = session.get('is_site_admin') or session.get('role') == 'admin' %}
|
||||
|
||||
<ul class="nav flex-column gap-1">
|
||||
{% if perms.get('view_dashboard', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.dashboard' }}" href="{{ url_for('panel.dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_players', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint in ['panel.players','panel.player_detail'] }}" href="{{ url_for('panel.players') }}">
|
||||
<i class="bi bi-people-fill"></i> Players
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_sessions', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.sessions' }}" href="{{ url_for('panel.sessions') }}">
|
||||
<i class="bi bi-clock-history"></i> Sessions
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_chat', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.chat' }}" href="{{ url_for('panel.chat') }}">
|
||||
<i class="bi bi-chat-dots-fill"></i> Chat
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_commands', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.commands' }}" href="{{ url_for('panel.commands') }}">
|
||||
<i class="bi bi-terminal-fill"></i> Commands
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_deaths', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.deaths' }}" href="{{ url_for('panel.deaths') }}">
|
||||
<i class="bi bi-heartbreak-fill"></i> Deaths
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_blocks', True) or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.blocks' }}" href="{{ url_for('panel.blocks') }}">
|
||||
<i class="bi bi-bricks"></i> Block Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_proxy') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.proxy' }}" href="{{ url_for('panel.proxy') }}">
|
||||
<i class="bi bi-diagram-3-fill"></i> Proxy Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_server_events') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.server_events' }}" href="{{ url_for('panel.server_events') }}">
|
||||
<i class="bi bi-server"></i> Server Events
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.get('view_perms') or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'panel.perms' }}" href="{{ url_for('panel.perms') }}">
|
||||
<i class="bi bi-shield-lock-fill"></i> Permissions
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
<!-- Gruppen-Switcher -->
|
||||
{% if not session.get('is_site_admin') and user_groups and user_groups|length > 1 %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Gruppe wechseln:</small>
|
||||
{% for g in user_groups %}
|
||||
<a href="{{ url_for('auth.switch_group', group_id=g.id) }}"
|
||||
class="btn btn-sm w-100 mt-1 {{ 'btn-success' if g.id == session.get('group_id') else 'btn-outline-secondary' }}">
|
||||
{{ g.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Admin-Links -->
|
||||
{% if session.get('role') == 'admin' and not session.get('is_site_admin') %}
|
||||
<a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1">
|
||||
<i class="bi bi-gear-fill"></i> Gruppe verwalten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if session.get('is_site_admin') %}
|
||||
{% if session.get('admin_viewing') %}
|
||||
<a href="{{ url_for('site_admin.stop_view') }}" class="btn btn-warning btn-sm mb-1">
|
||||
<i class="bi bi-arrow-left"></i> Zurück zum Admin
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('site_admin.dashboard') }}" class="btn btn-outline-danger btn-sm mb-1">
|
||||
<i class="bi bi-shield-fill"></i> Site Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-circle-fill text-success me-1" style="font-size:.5rem"></i>
|
||||
<span id="online-count">—</span> Online
|
||||
</div>
|
||||
<small class="text-muted d-block mb-1">{{ session.get('username', '') }}</small>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger btn-sm w-100">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Hauptinhalt ───────────────────────────────────── -->
|
||||
<div id="page-content" class="flex-grow-1 overflow-auto">
|
||||
<div class="topbar d-flex align-items-center justify-content-between px-4 py-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="sidebarToggle"><i class="bi bi-list"></i></button>
|
||||
<h6 class="mb-0 fw-semibold">{% block page_title %}{% endblock %}</h6>
|
||||
<small class="text-muted">{{ now.strftime('%d.%m.%Y %H:%M') }}</small>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="px-4 pt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="px-4 py-3">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
69
web/templates/blocks.html
Normal file
69
web/templates/blocks.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Block-Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="world" class="form-select form-select-sm">
|
||||
<option value="">All Worlds</option>
|
||||
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} block events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
|
||||
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small font-monospace">{{ r.block_type }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
|
||||
<td class="small">{{ r.tool or '—' }}</td>
|
||||
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
58
web/templates/chat.html
Normal file
58
web/templates/chat.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Chat Log{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small">Message</label>
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">Server</label>
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">Alle</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">From</label>
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">To</label>
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} messages</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
|
||||
<td class="small">{{ r.message }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
51
web/templates/commands.html
Normal file
51
web/templates/commands.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Commands{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} commands</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace text-warning">{{ r.command }}</td>
|
||||
<td class="small text-muted">
|
||||
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
221
web/templates/dashboard.html
Normal file
221
web/templates/dashboard.html
Normal file
@@ -0,0 +1,221 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- ── Statistik-Karten ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
{% set cards = [
|
||||
('Total Players', stats.players_total, 'bi-people-fill', 'success'),
|
||||
('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'),
|
||||
('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'),
|
||||
('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'),
|
||||
('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'),
|
||||
('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'),
|
||||
('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'),
|
||||
('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'),
|
||||
] %}
|
||||
{% for label, value, icon, color in cards %}
|
||||
<div class="col-6 col-md-3 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-{{ color }} bg-opacity-25">
|
||||
<i class="bi {{ icon }} text-{{ color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">{{ value | int }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Online-Spieler -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="online-table">
|
||||
{% if online %}
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Country</th><th>Since</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in online %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('player_detail', uuid='') }}">{{ s.username }}</a></td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
|
||||
<td class="small text-muted">{{ s.country or '—' }}</td>
|
||||
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-moon-stars-fill fs-3"></i><br>
|
||||
No players online
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Aktivität -->
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-activity me-2"></i>Last 24h Activity
|
||||
</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
|
||||
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.server_name or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Block-Events Chart -->
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="blockChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Todesursachen -->
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="deathChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Spieler -->
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{% for p in top_players %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}. {{ p.username }}</td>
|
||||
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-server me-2"></i>Server Events (last 24h)
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in server_events %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.server_name }}</td>
|
||||
<td class="small">{{ e.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Block-Chart
|
||||
const blockCtx = document.getElementById('blockChart');
|
||||
new Chart(blockCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ block_chart | map(attribute='day') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Block-Events',
|
||||
data: {{ block_chart | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: 'rgba(25,135,84,0.7)',
|
||||
borderColor: 'rgba(25,135,84,1)',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
|
||||
// Todesursachen-Chart
|
||||
const deathCtx = document.getElementById('deathChart');
|
||||
new Chart(deathCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
|
||||
datasets: [{
|
||||
data: {{ death_causes | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'],
|
||||
}]
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
|
||||
});
|
||||
|
||||
// Live Online-Count aktualisieren
|
||||
function refreshOnline() {
|
||||
fetch('/api/online')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('online-count').textContent = data.length;
|
||||
});
|
||||
}
|
||||
setInterval(refreshOnline, 30000);
|
||||
refreshOnline();
|
||||
</script>
|
||||
{% endblock %}
|
||||
49
web/templates/deaths.html
Normal file
49
web/templates/deaths.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Deaths{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="cause" class="form-select form-select-sm">
|
||||
<option value="">All Causes</option>
|
||||
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} deaths</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name }}</td>
|
||||
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
|
||||
<td class="small">{{ r.killer_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
|
||||
<td class="small">{{ r.exp_level }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.death_message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
46
web/templates/group_admin/base.html
Normal file
46
web/templates/group_admin/base.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Gruppen Admin{% endblock %} — {{ session.get('group_name','') }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-warning bg-opacity-75">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold text-dark" href="{{ url_for('group_admin.dashboard') }}">
|
||||
<i class="bi bi-gear-fill me-2"></i>{{ session.get('group_name', 'Gruppe') }} — Admin
|
||||
</a>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('group_admin.dashboard') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('group_admin.members') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.members' }}">Mitglieder</a>
|
||||
<a href="{{ url_for('group_admin.database') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.database' }}">Datenbank</a>
|
||||
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
|
||||
<i class="bi bi-grid me-1"></i>Panel
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
77
web/templates/group_admin/dashboard.html
Normal file
77
web/templates/group_admin/dashboard.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-gear-fill text-warning me-2"></i>Gruppenadmin: {{ session.get('group_name') }}</h2>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-warning">{{ stats.member_count }}</div>
|
||||
<div class="text-muted">Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold {{ 'text-success' if stats.db_configured else 'text-danger' }}">
|
||||
{{ 'Ja' if stats.db_configured else 'Nein' }}
|
||||
</div>
|
||||
<div class="text-muted">DB konfiguriert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 bg-secondary bg-opacity-25">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
|
||||
<div class="text-muted">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-people-fill me-2"></i>Schnellzugriff
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-people-fill me-2"></i>Mitglieder verwalten
|
||||
</a>
|
||||
<a href="{{ url_for('group_admin.database') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
|
||||
</a>
|
||||
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-speedometer2 me-2"></i>Panel öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Gruppeninfo</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5">Name</dt>
|
||||
<dd class="col-sm-7">{{ session.get('group_name') }}</dd>
|
||||
<dt class="col-sm-5">Deine Rolle</dt>
|
||||
<dd class="col-sm-7"><span class="badge bg-warning text-dark">Admin</span></dd>
|
||||
<dt class="col-sm-5">Datenbank</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if stats.db_configured %}
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Verbunden</span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
98
web/templates/group_admin/database.html
Normal file
98
web/templates/group_admin/database.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Datenbank{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-database-fill-gear me-2"></i>MC Datenbank konfigurieren</h2>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">Verbindungsdaten</div>
|
||||
<div class="card-body">
|
||||
{% if test_result is defined %}
|
||||
<div class="alert {{ 'alert-success' if test_result else 'alert-danger' }}">
|
||||
{% if test_result %}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>Verbindung erfolgreich! Daten wurden gespeichert.
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>Verbindung fehlgeschlagen: {{ test_error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Host *</label>
|
||||
<input type="text" name="host" class="form-control" required
|
||||
placeholder="localhost"
|
||||
value="{{ creds.host if creds else request.form.get('host', '') }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port *</label>
|
||||
<input type="number" name="port" class="form-control" required
|
||||
value="{{ creds.port if creds else request.form.get('port', '3306') }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Datenbank *</label>
|
||||
<input type="text" name="database" class="form-control" required
|
||||
placeholder="mclogger"
|
||||
value="{{ creds.database if creds else request.form.get('database', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Benutzer *</label>
|
||||
<input type="text" name="user" class="form-control" required
|
||||
value="{{ creds.user if creds else request.form.get('user', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Passwort</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
placeholder="{{ '(unverändert)' if creds else '' }}">
|
||||
{% if creds %}
|
||||
<div class="form-text">Leer lassen um das bestehende Passwort beizubehalten.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" name="action" value="test_save" class="btn btn-success">
|
||||
<i class="bi bi-plug-fill me-1"></i>Testen & Speichern
|
||||
</button>
|
||||
{% if creds %}
|
||||
<button type="submit" name="action" value="delete" class="btn btn-outline-danger"
|
||||
onclick="return confirm('DB-Konfiguration löschen?')">
|
||||
<i class="bi bi-trash3 me-1"></i>Entfernen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Info</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">
|
||||
Gib hier die Verbindungsdaten zu deiner <strong>MCLogger MySQL-Datenbank</strong> ein.
|
||||
Das Panel liest nur Daten (SELECT) — schreibender Zugriff ist nicht nötig.
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
Die Zugangsdaten werden <strong>verschlüsselt</strong> gespeichert und sind nur für deine Gruppe sichtbar.
|
||||
</p>
|
||||
<hr>
|
||||
<p class="small text-muted mb-1"><strong>Benötigte Tabellen:</strong></p>
|
||||
<ul class="small text-muted">
|
||||
<li>player_sessions</li>
|
||||
<li>chat_messages</li>
|
||||
<li>player_commands</li>
|
||||
<li>block_events</li>
|
||||
<li>player_deaths</li>
|
||||
<li>proxy_events</li>
|
||||
<li>server_events</li>
|
||||
<li>permission_changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
web/templates/group_admin/member_edit.html
Normal file
54
web/templates/group_admin/member_edit.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Berechtigungen – {{ member.username }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">Berechtigungen: <span class="text-warning">{{ member.username }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-lock-fill me-2"></i>Panel-Berechtigungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="member" {{ 'selected' if member.role == 'member' }}>Member</option>
|
||||
<option value="admin" {{ 'selected' if member.role == 'admin' }}>Admin</option>
|
||||
</select>
|
||||
<div class="form-text">Admins können Mitglieder und die DB-Verbindung verwalten.</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p class="form-label mb-2">Panel-Zugriff</p>
|
||||
<div class="row g-2">
|
||||
{% for key, label in all_permissions %}
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="perm_{{ key }}" id="perm_{{ key }}"
|
||||
{{ 'checked' if perms.get(key, True) }}>
|
||||
<label class="form-check-label" for="perm_{{ key }}">{{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-check-lg me-1"></i>Speichern
|
||||
</button>
|
||||
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
web/templates/group_admin/members.html
Normal file
65
web/templates/group_admin/members.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Mitglieder{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-people-fill me-2"></i>Mitglieder</h2>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Mitgliederliste -->
|
||||
<div class="col-md-8">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header">Aktuelle Mitglieder ({{ members|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}</td>
|
||||
<td>
|
||||
{% if m.role == 'admin' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Member</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if m.id != session.get('user_id') %}
|
||||
<a href="{{ url_for('group_admin.member_edit', user_id=m.id) }}" class="btn btn-sm btn-outline-warning" title="Berechtigungen">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('group_admin.member_remove', user_id=m.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{{ m.username }} entfernen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">Du</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer einladen (nur via Benutzername - Site Admin fügt Benutzer hinzu, Gruppen admin kann nur bestehende Mitglieder verwalten) -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Hinweis</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Neue Mitglieder müssen vom <strong>Site Admin</strong> zur Gruppe hinzugefügt werden.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Als Gruppenadmin kannst du Berechtigungen bestehender Mitglieder verwalten und Mitglieder entfernen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
49
web/templates/login.html
Normal file
49
web/templates/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center min-vh-100 bg-dark">
|
||||
<div class="card shadow-lg" style="width: 380px;">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-database-fill-gear text-success" style="font-size: 3rem;"></i>
|
||||
<h4 class="mt-2 fw-bold">MCLogger</h4>
|
||||
<p class="text-muted small">Admin-Interface · SimolZimol</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
||||
<input type="text" name="username" class="form-control" placeholder="admin" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
69
web/templates/panel/blocks.html
Normal file
69
web/templates/panel/blocks.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Block-Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="world" class="form-select form-select-sm">
|
||||
<option value="">All Worlds</option>
|
||||
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} block events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
|
||||
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small font-monospace">{{ r.block_type }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
|
||||
<td class="small">{{ r.tool or '—' }}</td>
|
||||
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
58
web/templates/panel/chat.html
Normal file
58
web/templates/panel/chat.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Chat Log{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small">Message</label>
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">Server</label>
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">Alle</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">From</label>
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small">To</label>
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} messages</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
|
||||
<td class="small">{{ r.message }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
51
web/templates/panel/commands.html
Normal file
51
web/templates/panel/commands.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Commands{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} commands</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace text-warning">{{ r.command }}</td>
|
||||
<td class="small text-muted">
|
||||
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
194
web/templates/panel/dashboard.html
Normal file
194
web/templates/panel/dashboard.html
Normal file
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ── Statistik-Karten ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
{% set cards = [
|
||||
('Total Players', stats.players_total, 'bi-people-fill', 'success'),
|
||||
('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'),
|
||||
('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'),
|
||||
('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'),
|
||||
('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'),
|
||||
('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'),
|
||||
('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'),
|
||||
('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'),
|
||||
] %}
|
||||
{% for label, value, icon, color in cards %}
|
||||
<div class="col-6 col-md-3 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-{{ color }} bg-opacity-25">
|
||||
<i class="bi {{ icon }} text-{{ color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">{{ value | int }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="online-table">
|
||||
{% if online %}
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Since</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in online %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('panel.player_detail', uuid=s.get('player_uuid','')) }}">{{ s.player_name }}</a></td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
|
||||
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-moon-stars-fill fs-3"></i><br>No players online
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-activity me-2"></i>Last 24h Activity
|
||||
</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td>
|
||||
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
|
||||
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
|
||||
</td>
|
||||
<td class="small">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.server_name or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="blockChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
|
||||
<div class="card-body">
|
||||
<canvas id="deathChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{% for p in top_players %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}. {{ p.username }}</td>
|
||||
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-server me-2"></i>Server Events (last 24h)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in server_events %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.server_name }}</td>
|
||||
<td class="small">{{ e.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const blockCtx = document.getElementById('blockChart');
|
||||
new Chart(blockCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ block_chart | map(attribute='day') | list | tojson }},
|
||||
datasets: [{ label: 'Block-Events', data: {{ block_chart | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: 'rgba(25,135,84,0.7)', borderColor: 'rgba(25,135,84,1)', borderWidth: 1 }]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
const deathCtx = document.getElementById('deathChart');
|
||||
new Chart(deathCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
|
||||
datasets: [{ data: {{ death_causes | map(attribute='cnt') | list | tojson }},
|
||||
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'] }]
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
|
||||
});
|
||||
function refreshOnline() {
|
||||
fetch('/api/online').then(r => r.json()).then(data => {
|
||||
document.getElementById('online-count').textContent = data.length;
|
||||
});
|
||||
}
|
||||
setInterval(refreshOnline, 30000);
|
||||
refreshOnline();
|
||||
</script>
|
||||
{% endblock %}
|
||||
49
web/templates/panel/deaths.html
Normal file
49
web/templates/panel/deaths.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Deaths{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="cause" class="form-select form-select-sm">
|
||||
<option value="">All Causes</option>
|
||||
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} deaths</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td class="small fw-semibold">{{ r.player_name }}</td>
|
||||
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
|
||||
<td class="small">{{ r.killer_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
|
||||
<td class="small">{{ r.exp_level }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
|
||||
<td class="small text-muted">{{ r.death_message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
24
web/templates/panel/no_db.html
Normal file
24
web/templates/panel/no_db.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}No Database{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-database-fill-x me-2"></i>Keine Datenbank{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 text-center">
|
||||
<i class="bi bi-database-fill-x display-1 text-muted mb-4"></i>
|
||||
<h3 class="mb-3">Keine Datenbank konfiguriert</h3>
|
||||
<p class="text-muted mb-4">
|
||||
Für diese Gruppe ist noch keine MC-Datenbank eingerichtet.
|
||||
{% if session.get('role') == 'admin' %}
|
||||
Du kannst die Verbindung als Gruppen-Admin konfigurieren.
|
||||
{% else %}
|
||||
Bitte wende dich an deinen Gruppenadmin.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if session.get('role') == 'admin' %}
|
||||
<a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
web/templates/panel/perms.html
Normal file
64
web/templates/panel/perms.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Permissions{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-shield-lock-fill me-2"></i>Permissions{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Target player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="plugin" class="form-select form-select-sm">
|
||||
<option value="">All Plugins</option>
|
||||
{% for pl in plugins %}<option {{ 'selected' if pl == plugin_filter }}>{{ pl }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="type" class="form-control form-control-sm" placeholder="Event type…" value="{{ etype }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.perms') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} permission events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Plugin</th><th>Event Type</th><th>Target Player</th><th>Actor</th><th>Target Type</th><th>Target ID</th><th>Action</th><th>Server</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge_colors = {
|
||||
'luckperms_permission_set': 'success',
|
||||
'luckperms_permission_unset': 'danger',
|
||||
'luckperms_parent_add': 'primary',
|
||||
'luckperms_parent_remove': 'warning',
|
||||
'luckperms_meta_set': 'info',
|
||||
'luckperms_meta_unset': 'secondary',
|
||||
'luckperms_group_create': 'light',
|
||||
'luckperms_group_delete': 'dark',
|
||||
} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.plugin_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-{{ badge_colors.get(r.event_type,'secondary') }} text-wrap text-start" style="font-size:.7rem;">{{ r.event_type }}</span></td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.actor_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.target_type or '—' }}</td>
|
||||
<td class="small text-muted text-truncate" style="max-width:120px;" title="{{ r.target_id }}">{{ r.target_id or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.action }}">{{ r.action or '—' }}</td>
|
||||
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">No permission events found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
142
web/templates/panel/player_detail.html
Normal file
142
web/templates/panel/player_detail.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ player.username }}{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-person-fill me-2"></i>{{ player.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% endif %}
|
||||
<table class="table table-sm mt-2 text-start">
|
||||
<tr><th>UUID</th><td class="small text-break">{{ player.uuid }}</td></tr>
|
||||
<tr><th>IP</th><td class="small">{{ player.ip_address or '—' }}</td></tr>
|
||||
<tr><th>Locale</th><td class="small">{{ player.locale or '—' }}</td></tr>
|
||||
<tr><th>Playtime</th><td>{{ player.total_playtime_sec | fmt_duration }}</td></tr>
|
||||
<tr><th>Since</th><td class="small">{{ player.first_seen | fmt_dt }}</td></tr>
|
||||
<tr><th>Last Seen</th><td class="small">{{ player.last_seen | fmt_dt }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-8">
|
||||
<ul class="nav nav-tabs mb-3" id="playerTabs">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-sessions">Sessions ({{ sessions|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-chat">Chat ({{ chat|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-cmds">Commands ({{ commands|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deaths">Deaths ({{ deaths|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-tp">Teleports ({{ teleports|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-proxy">Proxy ({{ proxy_events|length }})</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-sessions">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Login</th><th>Logout</th><th>Duration</th><th>Server</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}<tr>
|
||||
<td class="small text-nowrap">{{ s.login_time | fmt_dt }}</td>
|
||||
<td class="small text-nowrap">{{ s.logout_time | fmt_dt }}</td>
|
||||
<td class="small">{{ s.duration_sec | fmt_duration }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name or '—' }}</span></td>
|
||||
<td class="small text-muted">{{ s.ip_address or '—' }}</td>
|
||||
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No sessions</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-chat">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in chat %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small">{{ c.message }}</td>
|
||||
</tr>{% else %}<tr><td colspan="3" class="text-center text-muted">No chat messages</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-cmds">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Command</th><th>Position</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in commands %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace">{{ c.command }}</td>
|
||||
<td class="small text-muted">{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}</td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No commands</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-deaths">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Cause</th><th>Killer</th><th>Level</th><th>World</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in deaths %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ d.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-danger">{{ d.cause or '—' }}</span></td>
|
||||
<td class="small">{{ d.killer_name or '—' }}</td>
|
||||
<td class="small">{{ d.exp_level }}</td>
|
||||
<td class="small text-muted">{{ d.world }}</td>
|
||||
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No deaths</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-tp">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>From</th><th>To</th><th>Cause</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in teleports %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ t.timestamp | fmt_dt }}</td>
|
||||
<td class="small">{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }})</td>
|
||||
<td class="small">{{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }})</td>
|
||||
<td><span class="badge bg-info text-dark">{{ t.cause or '—' }}</span></td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No teleports</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-proxy">
|
||||
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Type</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in proxy_events %}<tr>
|
||||
<td class="small text-nowrap text-muted">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-primary">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.from_server or '—' }}</td>
|
||||
<td class="small">{{ e.to_server or '—' }}</td>
|
||||
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No proxy events</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Overview
|
||||
</a>
|
||||
{% endblock %}
|
||||
56
web/templates/panel/players.html
Normal file
56
web/templates/panel/players.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Players{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-people-fill me-2"></i>Players{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 mb-3">
|
||||
<div class="col-auto flex-grow-1">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search by player name…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success">Search</button>
|
||||
{% if search %}<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary ms-1">Reset</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>{{ total }} players found</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>IP</th><th>First Seen</th><th>Last Seen</th><th>Playtime</th><th>OP</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in players %}
|
||||
<tr>
|
||||
<td class="fw-semibold">
|
||||
<i class="bi bi-person-circle me-1 text-success"></i>{{ p.username }}
|
||||
</td>
|
||||
<td class="small text-muted">{{ p.ip_address or '—' }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.first_seen | fmt_dt }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.last_seen | fmt_dt }}</td>
|
||||
<td class="small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
<td>
|
||||
{% if p.is_op %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('panel.player_detail', uuid=p.uuid) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No players found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
51
web/templates/panel/proxy.html
Normal file
51
web/templates/panel/proxy.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Proxy Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-diagram-3-fill me-2"></i>Proxy Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.proxy') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} proxy events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Proxy</th><th>From</th><th>To</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge = {'login':'success','disconnect':'danger','server_switch':'primary','command':'warning','proxy_start':'info','proxy_stop':'dark'} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-{{ badge.get(r.event_type,'secondary') }}">{{ r.event_type }}</span></td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.proxy_name or '—' }}</span></td>
|
||||
<td class="small">{{ r.from_server or '—' }}</td>
|
||||
<td class="small">{{ r.to_server or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No proxy events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
49
web/templates/panel/server_events.html
Normal file
49
web/templates/panel/server_events.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Server Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-server me-2"></i>Server Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in etypes %}<option {{ 'selected' if t == etype }}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.server_events') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} server events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge = {'server_start':'success','server_stop':'danger','player_join':'info','player_quit':'secondary','player_kick':'warning'} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-{{ badge.get(r.event_type,'secondary') }}">{{ r.event_type }}</span></td>
|
||||
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small">{{ r.message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">No events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
56
web/templates/panel/sessions.html
Normal file
56
web/templates/panel/sessions.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sessions{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-clock-history me-2"></i>Sessions{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('panel.sessions') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} sessions</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Login</th><th>Logout</th><th>Duration</th><th>IP</th><th>Country</th><th>Client</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small fw-semibold">
|
||||
<a href="{{ url_for('panel.player_detail', uuid=r.player_uuid) }}" class="text-decoration-none">
|
||||
<i class="bi bi-person-circle me-1 text-success"></i>{{ r.player_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small text-muted text-nowrap">{{ r.login_time | fmt_dt }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ r.logout_time | fmt_dt }}</td>
|
||||
<td class="small">
|
||||
{% if r.logout_time %}{{ r.duration_sec | fmt_duration }}
|
||||
{% else %}<span class="badge bg-success">Online</span>{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ r.ip_address or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.country or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.client_version or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No sessions</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
74
web/templates/perms.html
Normal file
74
web/templates/perms.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Permissions{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-shield-lock-fill me-2"></i>Permissions{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Target player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="plugin" class="form-select form-select-sm">
|
||||
<option value="">All Plugins</option>
|
||||
{% for pl in plugins %}<option {{ 'selected' if pl == plugin_filter }}>{{ pl }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="type" class="form-control form-control-sm" placeholder="Event type (e.g. luckperms_permission_set)…" value="{{ etype }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('perms') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} permission events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Plugin</th>
|
||||
<th>Event Type</th>
|
||||
<th>Target Player</th>
|
||||
<th>Actor</th>
|
||||
<th>Target Type</th>
|
||||
<th>Target ID</th>
|
||||
<th>Action</th>
|
||||
<th>Server</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge_colors = {
|
||||
'luckperms_permission_set': 'success',
|
||||
'luckperms_permission_unset': 'danger',
|
||||
'luckperms_parent_add': 'primary',
|
||||
'luckperms_parent_remove': 'warning',
|
||||
'luckperms_meta_set': 'info',
|
||||
'luckperms_meta_unset': 'secondary',
|
||||
'luckperms_group_create': 'light',
|
||||
'luckperms_group_delete': 'dark',
|
||||
} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.plugin_name or '—' }}</span></td>
|
||||
<td><span class="badge bg-{{ badge_colors.get(r.event_type, 'secondary') }} text-wrap text-start" style="font-size:.7rem;">{{ r.event_type }}</span></td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td class="small">{{ r.actor_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.target_type or '—' }}</td>
|
||||
<td class="small text-muted text-truncate" style="max-width:120px;" title="{{ r.target_id }}">{{ r.target_id or '—' }}</td>
|
||||
<td class="small text-truncate" style="max-width:200px;" title="{{ r.action }}">{{ r.action or '—' }}</td>
|
||||
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">No permission events found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
182
web/templates/player_detail.html
Normal file
182
web/templates/player_detail.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ player.username }}{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-person-fill me-2"></i>{{ player.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Spieler-Info Karte -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% endif %}
|
||||
<table class="table table-sm mt-2 text-start">
|
||||
<tr><th>UUID</th><td class="small text-break">{{ player.uuid }}</td></tr>
|
||||
<tr><th>IP</th><td class="small">{{ player.ip_address or '—' }}</td></tr>
|
||||
<tr><th>Locale</th><td class="small">{{ player.locale or '—' }}</td></tr>
|
||||
<tr><th>Playtime</th><td>{{ player.total_playtime_sec | fmt_duration }}</td></tr>
|
||||
<tr><th>Since</th><td class="small">{{ player.first_seen | fmt_dt }}</td></tr>
|
||||
<tr><th>Last Seen</th><td class="small">{{ player.last_seen | fmt_dt }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-8">
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mb-3" id="playerTabs">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-sessions">Sessions ({{ sessions|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-chat">Chat ({{ chat|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-cmds">Commands ({{ commands|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deaths">Deaths ({{ deaths|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-tp">Teleports ({{ teleports|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-stats">Stats ({{ stats|length }})</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-proxy">Proxy ({{ proxy_events|length }})</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Sessions -->
|
||||
<div class="tab-pane fade show active" id="tab-sessions">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Login</th><th>Logout</th><th>Duration</th><th>Server</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}
|
||||
<tr>
|
||||
<td class="small text-nowrap">{{ s.login_time | fmt_dt }}</td>
|
||||
<td class="small text-nowrap">{{ s.logout_time | fmt_dt }}</td>
|
||||
<td class="small">{{ s.duration_sec | fmt_duration }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.server_name or '—' }}</span></td>
|
||||
<td class="small text-muted">{{ s.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="5" class="text-center text-muted">No sessions</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<div class="tab-pane fade" id="tab-chat">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in chat %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small">{{ c.message }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="3" class="text-center text-muted">No chat messages</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commands -->
|
||||
<div class="tab-pane fade" id="tab-cmds">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Command</th><th>Position</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in commands %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
|
||||
<td class="small font-monospace">{{ c.command }}</td>
|
||||
<td class="small text-muted">{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="text-center text-muted">No commands</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tode -->
|
||||
<div class="tab-pane fade" id="tab-deaths">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Cause</th><th>Killer</th><th>Level</th><th>World</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in deaths %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ d.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-danger">{{ d.cause or '—' }}</span></td>
|
||||
<td class="small">{{ d.killer_name or '—' }}</td>
|
||||
<td class="small">{{ d.exp_level }}</td>
|
||||
<td class="small text-muted">{{ d.world }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="5" class="text-center text-muted">No deaths</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teleports -->
|
||||
<div class="tab-pane fade" id="tab-tp">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>From</th><th>To</th><th>Cause</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in teleports %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ t.timestamp | fmt_dt }}</td>
|
||||
<td class="small">{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }})</td>
|
||||
<td class="small">{{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }})</td>
|
||||
<td><span class="badge bg-info text-dark">{{ t.cause or '—' }}</span></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="text-center text-muted">No teleports</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="tab-pane fade" id="tab-stats">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Type</th><th>Old</th><th>New</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in stats %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ s.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.event_type }}</span></td>
|
||||
<td class="small">{{ s.old_value or '—' }}</td>
|
||||
<td class="small">{{ s.new_value or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="text-center text-muted">No stats</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy-Events -->
|
||||
<div class="tab-pane fade" id="tab-proxy">
|
||||
<div class="table-responsive" style="max-height:400px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Type</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in proxy_events %}
|
||||
<tr>
|
||||
<td class="small text-nowrap text-muted">{{ e.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-primary">{{ e.event_type }}</span></td>
|
||||
<td class="small">{{ e.from_server or '—' }}</td>
|
||||
<td class="small">{{ e.to_server or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="text-center text-muted">No proxy events</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('players') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Overview
|
||||
</a>
|
||||
{% endblock %}
|
||||
72
web/templates/players.html
Normal file
72
web/templates/players.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Players{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-people-fill me-2"></i>Players{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Suche -->
|
||||
<form method="get" class="row g-2 mb-3">
|
||||
<div class="col-auto flex-grow-1">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search by player name…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success">Search</button>
|
||||
{% if search %}<a href="{{ url_for('players') }}" class="btn btn-outline-secondary ms-1">Reset</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>{{ total }} players found</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Player</th><th>IP</th><th>First Seen</th>
|
||||
<th>Last Seen</th><th>Playtime</th><th>OP</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in players %}
|
||||
<tr>
|
||||
<td class="fw-semibold">
|
||||
<i class="bi bi-person-circle me-1 text-success"></i>{{ p.username }}
|
||||
</td>
|
||||
<td class="small text-muted">{{ p.ip_address or '—' }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.first_seen | fmt_dt }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ p.last_seen | fmt_dt }}</td>
|
||||
<td class="small">{{ p.total_playtime_sec | fmt_duration }}</td>
|
||||
<td>
|
||||
{% if p.is_op %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('player_detail', uuid=p.uuid) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No players found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% for p in range(1, pages+1) %}
|
||||
<li class="page-item {{ 'active' if p == page }}">
|
||||
<a class="page-link" href="?{{ request.query_string.decode().replace('page='+page|string,'') }}&page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
51
web/templates/proxy.html
Normal file
51
web/templates/proxy.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Proxy Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-diagram-3-fill me-2"></i>Proxy Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in ['login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop'] %}
|
||||
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('proxy') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} proxy events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Player</th><th>Proxy</th><th>From</th><th>To</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge = {'login':'success','disconnect':'danger','server_switch':'primary','command':'warning','proxy_start':'info','proxy_stop':'dark'} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-{{ badge.get(r.event_type,'secondary') }}">{{ r.event_type }}</span></td>
|
||||
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ r.proxy_name or '—' }}</span></td>
|
||||
<td class="small">{{ r.from_server or '—' }}</td>
|
||||
<td class="small">{{ r.to_server or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No proxy events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
49
web/templates/server_events.html
Normal file
49
web/templates/server_events.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Server Events{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-server me-2"></i>Server Events{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in etypes %}<option {{ 'selected' if t == etype }}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('server_events') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} server events</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set badge = {'server_start':'success','server_stop':'danger','player_join':'info','player_quit':'secondary','player_kick':'warning'} %}
|
||||
<tr>
|
||||
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
|
||||
<td><span class="badge bg-{{ badge.get(r.event_type,'secondary') }}">{{ r.event_type }}</span></td>
|
||||
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small">{{ r.message or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">No events</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
56
web/templates/sessions.html
Normal file
56
web/templates/sessions.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sessions{% endblock %}
|
||||
{% block page_title %}<i class="bi bi-clock-history me-2"></i>Sessions{% endblock %}
|
||||
{% block content %}
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="server" class="form-select form-select-sm">
|
||||
<option value="">All Servers</option>
|
||||
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm">Filter</button>
|
||||
<a href="{{ url_for('sessions') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-header">{{ total }} sessions</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Player</th><th>Server</th><th>Login</th><th>Logout</th><th>Duration</th><th>IP</th><th>Country</th><th>Client</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="small fw-semibold">
|
||||
<a href="{{ url_for('player_detail', uuid=r.player_uuid) }}" class="text-decoration-none">
|
||||
<i class="bi bi-person-circle me-1 text-success"></i>{{ r.player_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
|
||||
<td class="small text-muted text-nowrap">{{ r.login_time | fmt_dt }}</td>
|
||||
<td class="small text-muted text-nowrap">{{ r.logout_time | fmt_dt }}</td>
|
||||
<td class="small">
|
||||
{% if r.logout_time %}{{ r.duration_sec | fmt_duration }}
|
||||
{% else %}<span class="badge bg-success">Online</span>{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ r.ip_address or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.country or '—' }}</td>
|
||||
<td class="small text-muted">{{ r.client_version or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No sessions</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user