Files
MClogger/web_all_content.txt
SimolZimol c9c684f97a modified: web/blueprints/auth.py
modified:   web/blueprints/group_admin.py
	modified:   web/blueprints/panel.py
	modified:   web/blueprints/site_admin.py
	modified:   web/templates/admin/base.html
	modified:   web/templates/admin/dashboard.html
	modified:   web/templates/admin/group_edit.html
	modified:   web/templates/admin/group_members.html
	modified:   web/templates/admin/groups.html
	modified:   web/templates/admin/user_edit.html
	modified:   web/templates/admin/users.html
	modified:   web/templates/auth/admin_login.html
	modified:   web/templates/auth/login.html
	modified:   web/templates/base.html
	modified:   web/templates/group_admin/base.html
	modified:   web/templates/group_admin/dashboard.html
	modified:   web/templates/group_admin/database.html
	modified:   web/templates/group_admin/member_edit.html
	modified:   web/templates/group_admin/members.html
	modified:   web/templates/panel/no_db.html
2026-04-01 02:55:32 +02:00

4450 lines
198 KiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# blueprints/__init__.py
"""
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
"""
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"))
"""
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"],
})
"""
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():
try:
groups = db.list_all_groups() or []
users = db.list_all_users() or []
for g in groups:
try:
g["has_db"] = db.has_db_configured(g["id"])
except Exception:
g["has_db"] = False
except Exception:
groups, users = [], []
stats = {
"group_count": len(groups),
"user_count": len(users),
"db_configured": sum(1 for g in groups if g.get("has_db")),
"admin_count": sum(1 for u in users if u.get("is_site_admin")),
}
return render_template("admin/dashboard.html", groups=groups, users=users, stats=stats)
# ──────────────────────────────────────────────────────────────
# 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))
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/toggle-role", methods=["POST"])
@admin_required
def group_member_toggle_role(group_id, user_id):
member = db.get_group_member(user_id, group_id)
if member:
import json as _json
new_role = "member" if member["role"] == "admin" else "admin"
perms = member["permissions"] if isinstance(member["permissions"], dict) else (_json.loads(member["permissions"]) if member["permissions"] else {})
db.update_member(user_id, group_id, new_role, perms)
flash(f"Rolle auf '{new_role}' geändert.", "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 Grup­pe, 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"))
<!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>
{% 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_new') }}" 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_new') }}" 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 %}
{% 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 %}
{% 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 %}
{% 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_new') }}" 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 %}
{% 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 %}
{% 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_new') }}" 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 %}
<!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>
<!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>
<!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>
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
<!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>
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
<!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>
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
{% 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 %}
"""
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)
"""
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") or "0.0.0.0"
PORT = int(os.getenv("PORT") or "5000")
DEBUG = (os.getenv("DEBUG") or "false").lower() == "true"
# ── Panel-Datenbank (Nutzer, Gruppen, Mitgliedschaften) ────
PANEL_DB_HOST = os.getenv("PANEL_DB_HOST") or "localhost"
PANEL_DB_PORT = int(os.getenv("PANEL_DB_PORT") or "3306")
PANEL_DB_USER = os.getenv("PANEL_DB_USER") or "root"
PANEL_DB_PASSWORD = os.getenv("PANEL_DB_PASSWORD") or ""
PANEL_DB_NAME = os.getenv("PANEL_DB_NAME") or "mclogger_panel"
# ── Credentials-Datenbank (verschlüsselte MC-DB-Zugangsdaten) ──
CREDS_DB_HOST = os.getenv("CREDS_DB_HOST") or os.getenv("PANEL_DB_HOST") or "localhost"
CREDS_DB_PORT = int(os.getenv("CREDS_DB_PORT") or os.getenv("PANEL_DB_PORT") or "3306")
CREDS_DB_USER = os.getenv("CREDS_DB_USER") or os.getenv("PANEL_DB_USER") or "root"
CREDS_DB_PASSWORD = os.getenv("CREDS_DB_PASSWORD") or os.getenv("PANEL_DB_PASSWORD") or ""
CREDS_DB_NAME = os.getenv("CREDS_DB_NAME") or "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,
}
"""
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")
"""
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