new file: .gitignore

new file:   README.md
	new file:   database/schema.sql
	new file:   paper-plugin/pom.xml
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/PaperLoggerPlugin.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/database/DatabaseManager.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/BlockListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/EntityListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/InventoryListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.java
	new file:   paper-plugin/src/main/java/de/simolzimol/mclogger/paper/listeners/WorldListener.java
	new file:   paper-plugin/src/main/resources/config.yml
	new file:   paper-plugin/src/main/resources/plugin.yml
	new file:   paper-plugin/target/classes/config.yml
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/PaperLoggerPlugin.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand$RsConsumer.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/commands/MCLoggerCommand.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager$ThrowingRunnable.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/database/DatabaseManager.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/BlockListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/EntityListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/InventoryListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/LuckPermsListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerChatCommandListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerDeathListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerMiscListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/PlayerSessionListener.class
	new file:   paper-plugin/target/classes/de/simolzimol/mclogger/paper/listeners/WorldListener.class
	new file:   paper-plugin/target/classes/plugin.yml
	new file:   paper-plugin/target/maven-archiver/pom.properties
	new file:   paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
	new file:   paper-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
	new file:   paper-plugin/target/mclogger-paper-1.0.0.jar
	new file:   paper-plugin/target/original-mclogger-paper-1.0.0.jar
	new file:   velocity-plugin/pom.xml
	new file:   velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.java
	new file:   velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.java
	new file:   velocity-plugin/src/main/java/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.java
	new file:   velocity-plugin/src/main/resources/velocity-config.yml
	new file:   velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/VelocityLoggerPlugin.class
	new file:   velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager$ThrowingRunnable.class
	new file:   velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/database/VelocityDatabaseManager.class
	new file:   velocity-plugin/target/classes/de/simolzimol/mclogger/velocity/listeners/VelocityEventListener.class
	new file:   velocity-plugin/target/classes/velocity-config.yml
	new file:   velocity-plugin/target/classes/velocity-plugin.json
	new file:   velocity-plugin/target/maven-archiver/pom.properties
	new file:   velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
	new file:   velocity-plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
	new file:   velocity-plugin/target/mclogger-velocity-1.0.0.jar
	new file:   velocity-plugin/target/original-mclogger-velocity-1.0.0.jar
	new file:   web/Dockerfile
	new file:   web/app.py
	new file:   web/blueprints/__init__.py
	new file:   web/blueprints/auth.py
	new file:   web/blueprints/group_admin.py
	new file:   web/blueprints/panel.py
	new file:   web/blueprints/site_admin.py
	new file:   web/config.py
	new file:   web/crypto.py
	new file:   web/docker-compose.yml
	new file:   web/panel_db.py
	new file:   web/requirements.txt
	new file:   web/static/css/style.css
	new file:   web/static/js/main.js
	new file:   web/templates/_pagination.html
	new file:   web/templates/admin/base.html
	new file:   web/templates/admin/dashboard.html
	new file:   web/templates/admin/group_edit.html
	new file:   web/templates/admin/group_members.html
	new file:   web/templates/admin/groups.html
	new file:   web/templates/admin/user_edit.html
	new file:   web/templates/admin/users.html
	new file:   web/templates/auth/admin_login.html
	new file:   web/templates/auth/login.html
	new file:   web/templates/base.html
	new file:   web/templates/blocks.html
	new file:   web/templates/chat.html
	new file:   web/templates/commands.html
	new file:   web/templates/dashboard.html
	new file:   web/templates/deaths.html
	new file:   web/templates/group_admin/base.html
	new file:   web/templates/group_admin/dashboard.html
	new file:   web/templates/group_admin/database.html
	new file:   web/templates/group_admin/member_edit.html
	new file:   web/templates/group_admin/members.html
	new file:   web/templates/login.html
	new file:   web/templates/panel/blocks.html
	new file:   web/templates/panel/chat.html
	new file:   web/templates/panel/commands.html
	new file:   web/templates/panel/dashboard.html
	new file:   web/templates/panel/deaths.html
	new file:   web/templates/panel/no_db.html
	new file:   web/templates/panel/perms.html
	new file:   web/templates/panel/player_detail.html
	new file:   web/templates/panel/players.html
	new file:   web/templates/panel/proxy.html
	new file:   web/templates/panel/server_events.html
	new file:   web/templates/panel/sessions.html
	new file:   web/templates/perms.html
	new file:   web/templates/player_detail.html
	new file:   web/templates/players.html
	new file:   web/templates/proxy.html
	new file:   web/templates/server_events.html
	new file:   web/templates/sessions.html
This commit is contained in:
SimolZimol
2026-04-01 01:36:01 +02:00
commit b918dadb0c
109 changed files with 9196 additions and 0 deletions

63
.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Maps - nicht hochladen (zu groß)
maps/
*.zip
# Raw logs - nicht hochladen
raw logs/
# Statistics - nicht hochladen
stats/
# Logs
*.log
logs/
# Environment variables
.env
.env.local
# Docker
.dockerignore
# OS specific
Thumbs.db
.DS_Store

280
README.md Normal file
View File

@@ -0,0 +1,280 @@
# MCLogger
**Minecraft 1.201.21 · Paper + Velocity · MariaDB · Flask-Admin**
Autor: **SimolZimol**
Umfassendes Logging-System für Minecraft-Netzwerke. Alle relevanten Events werden asynchron in einer MariaDB-Datenbank gespeichert und über ein modernes Flask-Webinterface auswertbar.
---
## Projektstruktur
```
Log/
├── database/
│ └── schema.sql ← MariaDB-Schema (einmalig ausführen)
├── paper-plugin/ ← Maven-Projekt für Paper 1.20-1.21
│ ├── pom.xml
│ └── src/main/
│ ├── java/de/simolzimol/mclogger/paper/
│ │ ├── PaperLoggerPlugin.java
│ │ ├── database/DatabaseManager.java
│ │ └── listeners/
│ │ ├── PlayerSessionListener.java
│ │ ├── PlayerChatCommandListener.java
│ │ ├── PlayerDeathListener.java
│ │ ├── PlayerMiscListener.java
│ │ ├── BlockListener.java
│ │ ├── EntityListener.java
│ │ ├── InventoryListener.java
│ │ └── WorldListener.java
│ └── resources/
│ ├── plugin.yml
│ └── config.yml
├── velocity-plugin/ ← Maven-Projekt für Velocity 3.x
│ ├── pom.xml
│ └── src/main/
│ ├── java/de/simolzimol/mclogger/velocity/
│ │ ├── VelocityLoggerPlugin.java
│ │ ├── database/VelocityDatabaseManager.java
│ │ └── listeners/VelocityEventListener.java
│ └── resources/
│ └── velocity-config.yml
└── web/ ← Python Flask Admin-Interface
├── app.py
├── config.py
├── requirements.txt
├── templates/
│ ├── base.html
│ ├── login.html
│ ├── dashboard.html
│ ├── players.html
│ ├── player_detail.html
│ ├── chat.html
│ ├── commands.html
│ ├── blocks.html
│ ├── deaths.html
│ ├── proxy.html
│ ├── sessions.html
│ ├── server_events.html
│ └── _pagination.html
└── static/
├── css/style.css
└── js/main.js
```
---
## Was wird geloggt?
### Paper-Server
| Kategorie | Events |
|-----------|--------|
| **Sessions** | Login, Logout, Kick (+ IP, Client-Version, Sprache) |
| **Chat** | Alle Chat-Nachrichten mit Welt und Server |
| **Commands** | Alle Spieler-Befehle mit Position |
| **Tode** | Ursache, Mörder, verlorene Items, XP-Level |
| **Respawn** | Bett / Ankerpunkt / Normalrespawn |
| **Teleport** | Alle Ursachen (Portal, Command, Plugin usw.) |
| **Gamemode** | Wechsel mit Ursache |
| **Level/XP** | Level-Änderungen |
| **Blöcke** | Break, Place, Ignite, Burn, Explode + Werkzeug, SilkTouch |
| **Schilder** | Alle Sign-Änderungen mit Inhalt |
| **Entities** | Spawn, Tod, Schaden (durch Spieler), Zähmen, Züchten, Explosion |
| **PvP** | Schaden zwischen Spielern |
| **Inventar** | Item aufheben/fallenlassen, Crafting, Verzauberung, Amboss, Handel |
| **Welt** | Wetter, Donner, Portal, Baum-/Pilzwachstum, Welt-Load/Unload |
| **Server** | Start, Stop, Player-Join, Player-Quit, Kick |
| **Diverses** | Bett betreten/verlassen, Hand-Item-Wechsel, Angeln, Schlafen, Entity-Interaktion, Welt-Wechsel |
### Velocity-Proxy
| Kategorie | Events |
|-----------|--------|
| **Verbindungen** | Login (+ IP, Protocol-Version, Client-Brand, Ping) |
| **Disconnect** | Grund + Sitzungsdauer |
| **Server-Wechsel** | Von → Nach-Server |
| **Chat** | Proxy-Level-Chat |
| **Commands** | Proxy-Level-Commands |
| **Proxy** | Start / Stop |
---
## Voraussetzungen
- **Java 17+**
- **Maven 3.8+**
- **MariaDB 10.6+** / MySQL 8+
- **Paper 1.201.21**
- **Velocity 3.x**
- **Python 3.10+**
---
## 1. Datenbank einrichten
```sql
-- Als root in MariaDB:
SOURCE /pfad/zu/database/schema.sql;
-- Benutzer anlegen:
CREATE USER 'mclogger'@'%' IDENTIFIED BY 'sicheres_passwort';
GRANT ALL PRIVILEGES ON mclogger.* TO 'mclogger'@'%';
FLUSH PRIVILEGES;
```
---
## 2. Paper-Plugin bauen & installieren
```bash
cd paper-plugin
mvn clean package -q
cp target/mclogger-paper-1.0.0.jar /dein/paper-server/plugins/
```
Konfiguration bearbeiten (`plugins/MCLogger/config.yml`):
```yaml
server:
name: "survival-01" # eindeutiger Name
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "sicheres_passwort"
```
Server neu starten.
---
## 3. Velocity-Plugin bauen & installieren
```bash
cd velocity-plugin
mvn clean package -q
cp target/mclogger-velocity-1.0.0.jar /dein/velocity-proxy/plugins/
```
Konfiguration bearbeiten (`plugins/mclogger-velocity/velocity-config.yml`):
```yaml
proxy:
name: "proxy-01"
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "sicheres_passwort"
```
Proxy neu starten.
---
## 4. Flask-Webinterface starten
```bash
cd web
# Python-Umgebung (empfohlen)
python -m venv venv
venv\Scripts\activate # Windows
source venv/bin/activate # Linux/Mac
pip install -r requirements.txt
# Umgebungsvariablen setzen (oder config.py direkt bearbeiten)
set MCLOGGER_DB_HOST=localhost
set MCLOGGER_DB_PASSWORD=sicheres_passwort
set MCLOGGER_ADMIN_PASSWORD=admin_passwort
set MCLOGGER_SECRET_KEY=zufaelliger_32_zeichen_schluessel
python app.py
```
Webinterface öffnen: **http://localhost:5000**
### Für Produktion (Linux mit Gunicorn)
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
Oder als systemd-Service:
```ini
[Unit]
Description=MCLogger Web
[Service]
WorkingDirectory=/opt/mclogger/web
Environment=MCLOGGER_DB_HOST=localhost
Environment=MCLOGGER_DB_PASSWORD=sicheres_passwort
Environment=MCLOGGER_ADMIN_PASSWORD=admin_passwort
Environment=MCLOGGER_SECRET_KEY=...
ExecStart=/opt/mclogger/web/venv/bin/gunicorn -w 2 -b 127.0.0.1:5000 app:app
Restart=always
[Install]
WantedBy=multi-user.target
```
---
## Webinterface-Features
| Seite | Beschreibung |
|-------|-------------|
| **Dashboard** | Live-Statistiken, Online-Spieler, Aktivitäts-Charts, Top-Playtime |
| **Spieler** | Suchbare Spielerliste mit Profil-Details |
| **Spielerprofil** | Sessions, Chat, Commands, Tode, Teleports, Stats, Proxy-Events in Tabs |
| **Sessions** | Alle Login-/Logout-Sitzungen mit Filterfunktion |
| **Chat** | Volltext-Suche in allen Chat-Nachrichten |
| **Commands** | Alle ausgeführten Befehle |
| **Block-Events** | Break/Place/Ignite usw. mit Positions-Info |
| **Tode** | Tode mit Ursache, Mörder und Todes-Meldung |
| **Proxy-Events** | Login, Disconnect, Server-Wechsel usw. |
| **Server-Events** | Start/Stop und reine Server-Ereignisse |
---
## Konfigurationsvariablen (Umgebungsvariablen)
| Variable | Standard | Beschreibung |
|----------|----------|-------------|
| `MCLOGGER_DB_HOST` | `localhost` | MariaDB-Host |
| `MCLOGGER_DB_PORT` | `3306` | MariaDB-Port |
| `MCLOGGER_DB_USER` | `mclogger` | DB-Benutzer |
| `MCLOGGER_DB_PASSWORD` | *(leer)* | DB-Passwort |
| `MCLOGGER_DB_NAME` | `mclogger` | Datenbankname |
| `MCLOGGER_HOST` | `0.0.0.0` | Webserver-Bind |
| `MCLOGGER_PORT` | `5000` | Webserver-Port |
| `MCLOGGER_ADMIN_USER` | `admin` | Admin-Login |
| `MCLOGGER_ADMIN_PASSWORD` | *(leer)* | Admin-Passwort |
| `MCLOGGER_SECRET_KEY` | *(unsicher)* | Flask Session-Key |
---
## Performance-Hinweise
- Alle Datenbank-Writes erfolgen **asynchron** → kein Tick-Lag
- HikariCP Connection-Pool voreingestellt auf 10 (Paper) / 5 (Velocity)
- Entity-Spawn-Logging ist standardmäßig **deaktiviert** (sehr viele Events)
- Block-Events können bei Farms sehr viele Einträge erzeugen → ggf. `blocks-break-only: true`
- Ein regelmäßiges DB-Backup empfiehlt sich (Events wachsen schnell)
- MariaDB: Index-Tuning für `timestamp`-Spalten ist bereits im Schema enthalten
---
## Lizenz
MIT Frei verwendbar, SimolZimol als Autor nennen.

360
database/schema.sql Normal file
View File

@@ -0,0 +1,360 @@
-- ============================================================
-- MCLogger - MariaDB Schema
-- Author: SimolZimol
-- Minecraft 1.20-1.21 | Paper + Velocity
-- ============================================================
CREATE DATABASE IF NOT EXISTS mclogger CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mclogger;
-- -------------------------------------------------------
-- Tabelle: servers
-- Registriert alle bekannten Server-Instanzen
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS servers (
id INT AUTO_INCREMENT PRIMARY KEY,
server_name VARCHAR(100) NOT NULL,
server_type ENUM('paper', 'velocity') NOT NULL DEFAULT 'paper',
ip_address VARCHAR(45),
mc_version VARCHAR(20),
first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_server_name (server_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: players
-- Bekannte Spieler mit Basisdaten
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS players (
uuid VARCHAR(36) PRIMARY KEY,
username VARCHAR(16) NOT NULL,
display_name VARCHAR(64),
ip_address VARCHAR(45),
locale VARCHAR(20),
first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
total_playtime_sec BIGINT DEFAULT 0,
is_op TINYINT(1) DEFAULT 0,
INDEX idx_username (username)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_sessions
-- Login-/Logout-Sitzungen
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_sessions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
player_uuid VARCHAR(36) NOT NULL,
player_name VARCHAR(16) NOT NULL,
server_name VARCHAR(100),
login_time TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
logout_time TIMESTAMP(3),
duration_sec INT,
ip_address VARCHAR(45),
client_version VARCHAR(20),
INDEX idx_ps_uuid (player_uuid),
INDEX idx_ps_server (server_name),
INDEX idx_ps_login (login_time)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_chat
-- Alle Chat-Nachrichten
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_chat (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
player_uuid VARCHAR(36),
player_name VARCHAR(16),
server_name VARCHAR(100),
world VARCHAR(100),
message TEXT NOT NULL,
channel VARCHAR(50) DEFAULT 'global',
INDEX idx_chat_uuid (player_uuid),
INDEX idx_chat_timestamp (timestamp),
INDEX idx_chat_server (server_name),
FULLTEXT INDEX ft_message (message)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_commands
-- Alle ausgeführten Befehle
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_commands (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
player_uuid VARCHAR(36),
player_name VARCHAR(16),
server_name VARCHAR(100),
world VARCHAR(100),
command TEXT NOT NULL,
x DOUBLE,
y DOUBLE,
z DOUBLE,
INDEX idx_cmd_uuid (player_uuid),
INDEX idx_cmd_timestamp (timestamp),
INDEX idx_cmd_server (server_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: block_events
-- Block-Break / Block-Place und weitere Block-Aktionen
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS block_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type ENUM('break','place','ignite','burn','explode','fade','grow','dispense') NOT NULL,
player_uuid VARCHAR(36),
player_name VARCHAR(16),
server_name VARCHAR(100),
world VARCHAR(100) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL,
block_type VARCHAR(100) NOT NULL,
block_data VARCHAR(255),
tool VARCHAR(100),
is_silk TINYINT(1) DEFAULT 0,
INDEX idx_be_player (player_uuid),
INDEX idx_be_timestamp (timestamp),
INDEX idx_be_world (world),
INDEX idx_be_type (event_type),
INDEX idx_be_server (server_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_deaths
-- Tode mit vollem Kontext
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_deaths (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
player_uuid VARCHAR(36) NOT NULL,
player_name VARCHAR(16) NOT NULL,
server_name VARCHAR(100),
world VARCHAR(100),
x DOUBLE,
y DOUBLE,
z DOUBLE,
death_message TEXT,
cause VARCHAR(100),
killer_uuid VARCHAR(36),
killer_name VARCHAR(100),
killer_type VARCHAR(100),
exp_level INT,
items_lost JSON,
INDEX idx_deaths_uuid (player_uuid),
INDEX idx_deaths_timestamp (timestamp)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: entity_events
-- Entity-Spawns, -Tode, Schaden usw.
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS entity_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type ENUM('spawn','death','damage','tame','breed','transform','explode') NOT NULL,
server_name VARCHAR(100),
world VARCHAR(100),
x DOUBLE,
y DOUBLE,
z DOUBLE,
entity_type VARCHAR(100) NOT NULL,
entity_uuid VARCHAR(36),
entity_name VARCHAR(100),
player_uuid VARCHAR(36),
player_name VARCHAR(16),
cause VARCHAR(100),
damage DOUBLE,
details JSON,
INDEX idx_ee_timestamp (timestamp),
INDEX idx_ee_type (event_type),
INDEX idx_ee_entity (entity_type),
INDEX idx_ee_player (player_uuid),
INDEX idx_ee_server (server_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_teleports
-- Alle Teleportationen (Commands, Death, etc.)
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_teleports (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
player_uuid VARCHAR(36) NOT NULL,
player_name VARCHAR(16) NOT NULL,
server_name VARCHAR(100),
from_world VARCHAR(100),
from_x DOUBLE,
from_y DOUBLE,
from_z DOUBLE,
to_world VARCHAR(100),
to_x DOUBLE,
to_y DOUBLE,
to_z DOUBLE,
cause VARCHAR(100),
INDEX idx_tp_uuid (player_uuid),
INDEX idx_tp_timestamp (timestamp)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: inventory_events
-- Item-Picks, Drops, Inventory-Klicks
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS inventory_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type ENUM('pickup','drop','click','craft','enchant','anvil','trade') NOT NULL,
player_uuid VARCHAR(36) NOT NULL,
player_name VARCHAR(16) NOT NULL,
server_name VARCHAR(100),
world VARCHAR(100),
x DOUBLE,
y DOUBLE,
z DOUBLE,
item_type VARCHAR(100),
item_amount INT,
item_meta JSON,
slot INT,
inventory_type VARCHAR(100),
INDEX idx_inv_uuid (player_uuid),
INDEX idx_inv_timestamp (timestamp),
INDEX idx_inv_type (event_type)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: player_stats
-- Gamemode-Wechsel, Level-Änderungen, OP-Status usw.
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS player_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type VARCHAR(100) NOT NULL,
player_uuid VARCHAR(36) NOT NULL,
player_name VARCHAR(16) NOT NULL,
server_name VARCHAR(100),
old_value VARCHAR(255),
new_value VARCHAR(255),
details JSON,
INDEX idx_pst_uuid (player_uuid),
INDEX idx_pst_timestamp (timestamp),
INDEX idx_pst_type (event_type)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: world_events
-- Wetter, Zeit, Explosionen, Portale usw.
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS world_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type VARCHAR(100) NOT NULL,
server_name VARCHAR(100),
world VARCHAR(100),
x DOUBLE,
y DOUBLE,
z DOUBLE,
details JSON,
INDEX idx_we_timestamp (timestamp),
INDEX idx_we_world (world),
INDEX idx_we_type (event_type)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: server_events
-- Server-Start, -Stop, Plugin-Events, Konsolen-Commands
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS server_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type VARCHAR(100) NOT NULL,
server_name VARCHAR(100),
message TEXT,
details JSON,
INDEX idx_se_timestamp (timestamp),
INDEX idx_se_type (event_type),
INDEX idx_se_server (server_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: proxy_events
-- Velocity: Login, Disconnect, Server-Wechsel
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS proxy_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type ENUM('login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop') NOT NULL,
player_uuid VARCHAR(36),
player_name VARCHAR(16),
proxy_name VARCHAR(100),
from_server VARCHAR(100),
to_server VARCHAR(100),
ip_address VARCHAR(45),
details JSON,
INDEX idx_pe_uuid (player_uuid),
INDEX idx_pe_timestamp (timestamp),
INDEX idx_pe_type (event_type),
INDEX idx_pe_proxy (proxy_name)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: sign_edits
-- SignChangeEvents
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS sign_edits (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
player_uuid VARCHAR(36),
player_name VARCHAR(16),
server_name VARCHAR(100),
world VARCHAR(100),
x INT,
y INT,
z INT,
line1 VARCHAR(255),
line2 VARCHAR(255),
line3 VARCHAR(255),
line4 VARCHAR(255),
INDEX idx_sign_uuid (player_uuid),
INDEX idx_sign_timestamp (timestamp)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- Tabelle: plugin_events
-- LuckPerms Berechtigungsänderungen und andere Plugin-Events
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS plugin_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
event_type VARCHAR(100) NOT NULL,
plugin_name VARCHAR(100),
server_name VARCHAR(100),
player_uuid VARCHAR(36),
player_name VARCHAR(16),
actor_uuid VARCHAR(36),
actor_name VARCHAR(64),
target_type VARCHAR(50),
target_id VARCHAR(100),
action VARCHAR(255),
details JSON,
INDEX idx_plev_uuid (player_uuid),
INDEX idx_plev_timestamp (timestamp),
INDEX idx_plev_type (event_type),
INDEX idx_plev_plugin (plugin_name),
INDEX idx_plev_actor (actor_uuid)
) ENGINE=InnoDB;
-- -------------------------------------------------------
-- View: v_recent_activity - Letzte 24h kompakt
-- -------------------------------------------------------
CREATE OR REPLACE VIEW v_recent_activity AS
SELECT 'chat' AS source, timestamp, player_name, server_name, message AS detail FROM player_chat WHERE timestamp >= NOW() - INTERVAL 24 HOUR
UNION ALL
SELECT 'command' AS source, timestamp, player_name, server_name, command AS detail FROM player_commands WHERE timestamp >= NOW() - INTERVAL 24 HOUR
UNION ALL
SELECT 'block' AS source, timestamp, player_name, server_name, CONCAT(event_type,' ',block_type,' at ',world,' ',x,',',y,',',z) AS detail FROM block_events WHERE timestamp >= NOW() - INTERVAL 24 HOUR
UNION ALL
SELECT 'death' AS source, timestamp, player_name, server_name, death_message AS detail FROM player_deaths WHERE timestamp >= NOW() - INTERVAL 24 HOUR
ORDER BY timestamp DESC;

113
paper-plugin/pom.xml Normal file
View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.simolzimol</groupId>
<artifactId>mclogger-paper</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MCLogger-Paper</name>
<description>Comprehensive Minecraft event logger</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>lucko</id>
<url>https://repo.lucko.me/</url>
</repository>
</repositories>
<dependencies>
<!-- Paper API 1.21 -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- HikariCP Connection Pooling -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<!-- MariaDB JDBC Driver -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.4.1</version>
</dependency>
<!-- Gson für JSON-Serialisierung -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
<scope>provided</scope>
</dependency>
<!-- LuckPerms API (optional / soft-depend) -->
<dependency>
<groupId>net.luckperms</groupId>
<artifactId>api</artifactId>
<version>5.4</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<!-- Shade: alle Abhängigkeiten in eine JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>de.simolzimol.mclogger.lib.hikari</shadedPattern>
</relocation>
<relocation>
<pattern>org.mariadb</pattern>
<shadedPattern>de.simolzimol.mclogger.lib.mariadb</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/DEPENDENCIES</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,118 @@
package de.simolzimol.mclogger.paper;
import de.simolzimol.mclogger.paper.commands.MCLoggerCommand;
import de.simolzimol.mclogger.paper.database.DatabaseManager;
import de.simolzimol.mclogger.paper.listeners.*;
import net.luckperms.api.LuckPerms;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
/**
* MCLogger Paper Plugin entry point
*
* @author SimolZimol
* @version 1.0.0
*/
public class PaperLoggerPlugin extends JavaPlugin {
private static PaperLoggerPlugin instance;
private DatabaseManager db;
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
// Establish database connection
db = new DatabaseManager(this);
if (!db.connect()) {
getLogger().severe("[MCLogger] Could not connect to database disabling plugin.");
getServer().getPluginManager().disablePlugin(this);
return;
}
// Register plugin messaging channel towards the Velocity proxy
getServer().getMessenger().registerOutgoingPluginChannel(this, "mclogger:logged");
// Register all listeners
registerListeners();
// LuckPerms listener (soft-depend)
registerLuckPermsListener();
// Register /mclogger command
MCLoggerCommand cmdHandler = new MCLoggerCommand(this);
PluginCommand cmd = getCommand("mclogger");
if (cmd != null) {
cmd.setExecutor(cmdHandler);
cmd.setTabCompleter(cmdHandler);
}
// Server-Start Event loggen
Map<String, Object> details = new HashMap<>();
details.put("version", getServer().getVersion());
details.put("bukkit_version", getServer().getBukkitVersion());
details.put("online_mode", getServer().getOnlineMode());
details.put("max_players", getServer().getMaxPlayers());
db.insertServerEvent("server_start", "Server started", details);
getLogger().info("[MCLogger] started successfully! Server: " + db.getServerName());
}
@Override
public void onDisable() {
if (db != null) {
Map<String, Object> details = new HashMap<>();
details.put("online_players", getServer().getOnlinePlayers().size());
db.insertServerEvent("server_stop", "Server stopping", details);
// Short delay to let async tasks finish
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
db.disconnect();
}
getLogger().info("[MCLogger] disabled.");
}
private void registerListeners() {
PluginManager pm = getServer().getPluginManager();
pm.registerEvents(new PlayerSessionListener(this), this);
pm.registerEvents(new PlayerChatCommandListener(this), this);
pm.registerEvents(new PlayerDeathListener(this), this);
pm.registerEvents(new PlayerMiscListener(this), this);
pm.registerEvents(new BlockListener(this), this);
pm.registerEvents(new EntityListener(this), this);
pm.registerEvents(new InventoryListener(this), this);
pm.registerEvents(new WorldListener(this), this);
getLogger().info("[MCLogger] All listeners registered.");
}
/**
* Registers the LuckPerms listener only if LuckPerms is actually loaded (softdepend).
*/
private void registerLuckPermsListener() {
if (getServer().getPluginManager().getPlugin("LuckPerms") == null) {
getLogger().info("[MCLogger] LuckPerms not found permission logging disabled.");
return;
}
try {
RegisteredServiceProvider<LuckPerms> provider =
getServer().getServicesManager().getRegistration(LuckPerms.class);
if (provider != null) {
new LuckPermsListener(this, provider.getProvider());
} else {
getLogger().warning("[MCLogger] LuckPerms service not available.");
}
} catch (Exception e) {
getLogger().log(Level.WARNING, "[MCLogger] Error registering LuckPerms listener.", e);
}
}
public static PaperLoggerPlugin getInstance() { return instance; }
public DatabaseManager getDb() { return db; }
}

View File

@@ -0,0 +1,484 @@
package de.simolzimol.mclogger.paper.commands;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.jetbrains.annotations.NotNull;
import java.sql.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* In-game command class for /mclogger.
*
* Sub-commands:
* /mclogger help Help (mclogger.use)
* /mclogger status DB status (mclogger.admin)
* /mclogger reload Reload config (mclogger.admin)
* /mclogger chat <player> [page] Chat log (mclogger.view.chat)
* /mclogger commands <player> [page] Command log (mclogger.view.commands)
* /mclogger deaths <player> [page] Death log (mclogger.view.deaths)
* /mclogger sessions <player> [page] Session log (mclogger.view.sessions)
* /mclogger blocks <x> <y> <z> [page] Block log (mclogger.view.blocks)
* /mclogger perms player <name> [page] Perms by target player (mclogger.view.perms)
* /mclogger perms actor <name> [page] Perms by actor (mclogger.view.perms)
* /mclogger perms group <name> [page] Perms by group (mclogger.view.perms)
* /mclogger perms all [page] All perm events (mclogger.view.perms)
*
* @author SimolZimol
*/
public class MCLoggerCommand implements CommandExecutor, TabCompleter {
private static final int PAGE_SIZE = 8;
private final PaperLoggerPlugin plugin;
public MCLoggerCommand(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
// --------------------------------------------------------
// Tab-Completion
// --------------------------------------------------------
@Override
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command cmd,
@NotNull String label, String[] args) {
if (!sender.hasPermission("mclogger.use")) return List.of();
if (args.length == 1) {
List<String> subs = new ArrayList<>(List.of("help", "status", "reload",
"chat", "commands", "deaths", "sessions", "blocks", "perms"));
String partial = args[0].toLowerCase();
subs.removeIf(s -> !s.startsWith(partial));
return subs;
}
if (args.length == 2 && args[0].equalsIgnoreCase("perms")) {
return List.of("player", "actor", "group", "all").stream()
.filter(s -> s.startsWith(args[1].toLowerCase()))
.collect(Collectors.toList());
}
if (args.length == 3 && args[0].equalsIgnoreCase("perms")
&& (args[1].equalsIgnoreCase("player") || args[1].equalsIgnoreCase("actor"))) {
return plugin.getServer().getOnlinePlayers().stream()
.map(p -> p.getName())
.filter(n -> n.toLowerCase().startsWith(args[2].toLowerCase()))
.collect(Collectors.toList());
}
if (args.length == 2) {
String sub = args[0].toLowerCase();
return switch (sub) {
case "chat", "commands", "deaths", "sessions", "perms" ->
plugin.getServer().getOnlinePlayers().stream()
.map(p -> p.getName())
.filter(n -> n.toLowerCase().startsWith(args[1].toLowerCase()))
.toList();
default -> List.of();
};
}
return List.of();
}
// --------------------------------------------------------
// Command handler
// --------------------------------------------------------
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd,
@NotNull String label, String[] args) {
if (!sender.hasPermission("mclogger.use")) {
send(sender, NamedTextColor.RED, "You don't have permission to use this command.");
return true;
}
if (args.length == 0) {
handleHelp(sender);
return true;
}
return switch (args[0].toLowerCase()) {
case "help" -> { handleHelp(sender); yield true; }
case "status" -> { handleStatus(sender); yield true; }
case "reload" -> { handleReload(sender); yield true; }
case "chat" -> { handleChat(sender, args); yield true; }
case "commands" -> { handleCommands(sender, args); yield true; }
case "deaths" -> { handleDeaths(sender, args); yield true; }
case "sessions" -> { handleSessions(sender, args); yield true; }
case "blocks" -> { handleBlocks(sender, args); yield true; }
case "perms" -> { handlePerms(sender, args); yield true; }
default -> { handleHelp(sender); yield true; }
};
}
// --------------------------------------------------------
// Sub-commands
// --------------------------------------------------------
private void handleHelp(CommandSender s) {
sendHeader(s, "MCLogger Help");
sendLine(s, "/mcl help", "This help");
if (s.hasPermission("mclogger.admin")) {
sendLine(s, "/mcl status", "Database status");
sendLine(s, "/mcl reload", "Reload configuration");
}
if (s.hasPermission("mclogger.view.chat"))
sendLine(s, "/mcl chat <player> [page]", "Chat history");
if (s.hasPermission("mclogger.view.commands"))
sendLine(s, "/mcl commands <player> [page]", "Command history");
if (s.hasPermission("mclogger.view.deaths"))
sendLine(s, "/mcl deaths <player> [page]", "Death history");
if (s.hasPermission("mclogger.view.sessions"))
sendLine(s, "/mcl sessions <player> [page]", "Session history");
if (s.hasPermission("mclogger.view.blocks"))
sendLine(s, "/mcl blocks <x> <y> <z> [page]","Block log at position");
if (s.hasPermission("mclogger.view.perms"))
sendLine(s, "/mcl perms <player> [page]", "LuckPerms changes");
}
private void handleStatus(CommandSender s) {
requirePerm(s, "mclogger.admin", () -> {
boolean ok = plugin.getDb().isConnected();
send(s, ok ? NamedTextColor.GREEN : NamedTextColor.RED,
"Database connection: " + (ok ? "✔ CONNECTED" : "✘ DISCONNECTED"));
send(s, NamedTextColor.AQUA, "Server: " + plugin.getDb().getServerName()
+ " | ID: " + plugin.getDb().getServerId());
});
}
private void handleReload(CommandSender s) {
requirePerm(s, "mclogger.admin", () -> {
plugin.reloadConfig();
send(s, NamedTextColor.GREEN, "Configuration reloaded.");
});
}
// -- Chat -----------------------------------------------
private void handleChat(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.chat", () -> {
if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl chat <player> [page]"); return; }
String player = args[1];
int page = parsePage(args, 2);
runQuery(s,
"SELECT timestamp, message FROM player_chat " +
"WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
player, page,
rs -> {
sendHeader(s, "Chat: " + player + " (page " + page + ")");
while (rs.next()) {
send(s, NamedTextColor.GRAY, "[" + fmtTs(rs.getString("timestamp")) + "] "
+ rs.getString("message"));
}
});
});
}
// -- Commands -------------------------------------------
private void handleCommands(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.commands", () -> {
if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl commands <player> [page]"); return; }
String player = args[1];
int page = parsePage(args, 2);
runQuery(s,
"SELECT timestamp, command FROM player_commands " +
"WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
player, page,
rs -> {
sendHeader(s, "Commands: " + player + " (page " + page + ")");
while (rs.next()) {
send(s, NamedTextColor.GRAY, "[" + fmtTs(rs.getString("timestamp")) + "] "
+ rs.getString("command"));
}
});
});
}
// -- Deaths ---------------------------------------------
private void handleDeaths(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.deaths", () -> {
if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl deaths <player> [page]"); return; }
String player = args[1];
int page = parsePage(args, 2);
runQuery(s,
"SELECT timestamp, death_message, world, x, y, z FROM player_deaths " +
"WHERE player_name = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
player, page,
rs -> {
sendHeader(s, "Deaths: " + player + " (page " + page + ")");
while (rs.next()) {
send(s, NamedTextColor.RED,
"[" + fmtTs(rs.getString("timestamp")) + "] " + rs.getString("death_message")
+ " @ " + rs.getString("world") + " "
+ (int)rs.getDouble("x") + "/"
+ (int)rs.getDouble("y") + "/"
+ (int)rs.getDouble("z"));
}
});
});
}
// -- Sessions -------------------------------------------
private void handleSessions(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.sessions", () -> {
if (args.length < 2) { send(s, NamedTextColor.YELLOW, "Usage: /mcl sessions <player> [page]"); return; }
String player = args[1];
int page = parsePage(args, 2);
runQuery(s,
"SELECT login_time, logout_time, duration_sec, ip_address, country, server_name FROM player_sessions " +
"WHERE player_name = ? ORDER BY login_time DESC LIMIT ? OFFSET ?",
player, page,
rs -> {
sendHeader(s, "Sessions: " + player + " (page " + page + ")");
while (rs.next()) {
long dur = rs.getLong("duration_sec");
String durStr = dur > 0 ? formatDuration(dur) : "ongoing";
String country = rs.getString("country");
String countryStr = (country != null && !country.isEmpty()) ? " [" + country + "]" : "";
send(s, NamedTextColor.AQUA,
"[" + fmtTs(rs.getString("login_time")) + "] "
+ rs.getString("server_name") + " " + durStr
+ " IP: " + rs.getString("ip_address") + countryStr);
}
});
});
}
// -- Blocks ---------------------------------------------
private void handleBlocks(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.blocks", () -> {
if (args.length < 4) { send(s, NamedTextColor.YELLOW, "Usage: /mcl blocks <x> <y> <z> [page]"); return; }
int bx, by, bz;
try { bx = Integer.parseInt(args[1]); by = Integer.parseInt(args[2]); bz = Integer.parseInt(args[3]); }
catch (NumberFormatException ex) { send(s, NamedTextColor.RED, "Coordinates must be integers."); return; }
int page = parsePage(args, 4);
runQuery(s,
"SELECT timestamp, player_name, event_type, block_type FROM block_events " +
"WHERE x = ? AND y = ? AND z = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
bx, by, bz, page,
rs -> {
sendHeader(s, "Blocks @ " + bx + "/" + by + "/" + bz + " (page " + page + ")");
while (rs.next()) {
send(s, NamedTextColor.GOLD,
"[" + fmtTs(rs.getString("timestamp")) + "] "
+ rs.getString("player_name") + " "
+ rs.getString("event_type") + " "
+ rs.getString("block_type"));
}
});
});
}
// -- Perms ----------------------------------------------
private void handlePerms(CommandSender s, String[] args) {
requirePerm(s, "mclogger.view.perms", () -> {
if (args.length < 2) {
send(s, NamedTextColor.YELLOW,
"Usage: /mcl perms <player|actor|group|all> [name] [page]");
return;
}
String type = args[1].toLowerCase();
switch (type) {
case "all" -> {
int page = parsePage(args, 2);
runQueryParams(s,
"SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " +
"WHERE plugin_name = 'LuckPerms' ORDER BY timestamp DESC LIMIT ? OFFSET ?",
List.of(), page,
rs -> {
sendHeader(s, "All Permission Events (page " + page + ")");
while (rs.next()) printPermRow(s, rs);
});
}
case "player" -> {
if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms player <name> [page]"); return; }
String name = args[2]; int page = parsePage(args, 3);
runQueryParams(s,
"SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " +
"WHERE plugin_name = 'LuckPerms' AND (player_name = ? OR player_name LIKE ?) ORDER BY timestamp DESC LIMIT ? OFFSET ?",
List.of(name, name + "@%"), page,
rs -> {
sendHeader(s, "Perms for player " + name + " (page " + page + ")");
while (rs.next()) printPermRow(s, rs);
});
}
case "actor" -> {
if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms actor <name> [page]"); return; }
String name = args[2]; int page = parsePage(args, 3);
runQueryParams(s,
"SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " +
"WHERE plugin_name = 'LuckPerms' AND (actor_name = ? OR actor_name LIKE ?) ORDER BY timestamp DESC LIMIT ? OFFSET ?",
List.of(name, name + "@%"), page,
rs -> {
sendHeader(s, "Perms by actor " + name + " (page " + page + ")");
while (rs.next()) printPermRow(s, rs);
});
}
case "group" -> {
if (args.length < 3) { send(s, NamedTextColor.YELLOW, "Usage: /mcl perms group <name> [page]"); return; }
String name = args[2]; int page = parsePage(args, 3);
runQueryParams(s,
"SELECT timestamp, event_type, player_name, actor_name, target_type, action, server_name FROM plugin_events " +
"WHERE plugin_name = 'LuckPerms' AND target_type = 'group' AND target_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
List.of(name), page,
rs -> {
sendHeader(s, "Perms for group " + name + " (page " + page + ")");
while (rs.next()) printPermRow(s, rs);
});
}
default -> send(s, NamedTextColor.YELLOW,
"Usage: /mcl perms <player|actor|group|all> [name] [page]");
}
});
}
private void printPermRow(CommandSender s, ResultSet rs) throws SQLException {
String srv = rs.getString("server_name");
String srvStr = (srv != null && !srv.isEmpty()) ? " [" + srv + "]" : "";
send(s, NamedTextColor.LIGHT_PURPLE,
"[" + fmtTs(rs.getString("timestamp")) + "]" + srvStr + " "
+ rs.getString("actor_name") + "" + rs.getString("action")
+ (rs.getString("player_name") != null ? " (player: " + rs.getString("player_name") + ")" : ""));
}
// --------------------------------------------------------
// DB helper infrastructure (sync acceptable for short admin queries)
// --------------------------------------------------------
@FunctionalInterface
interface RsConsumer { void accept(ResultSet rs) throws SQLException; }
/** Executes a paginated query asynchronously (acceptable for admin commands). */
private void runQuery(CommandSender s, String sql, String player, int page, RsConsumer consumer) {
int offset = (page - 1) * PAGE_SIZE;
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try (Connection con = plugin.getDb().getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, player);
ps.setInt(2, PAGE_SIZE);
ps.setInt(3, offset);
ResultSet rs = ps.executeQuery();
plugin.getServer().getScheduler().runTask(plugin, () -> {
try { consumer.accept(rs); }
catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); }
});
} catch (SQLException ex) {
send(s, NamedTextColor.RED, "Database error: " + ex.getMessage());
}
});
}
/** Overload for queries with a variable list of leading params before LIMIT/OFFSET. */
private void runQueryParams(CommandSender s, String sql, List<String> params, int page, RsConsumer consumer) {
int offset = (page - 1) * PAGE_SIZE;
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try (Connection con = plugin.getDb().getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
int i = 1;
for (String p : params) ps.setString(i++, p);
ps.setInt(i++, PAGE_SIZE);
ps.setInt(i, offset);
ResultSet rs = ps.executeQuery();
plugin.getServer().getScheduler().runTask(plugin, () -> {
try { consumer.accept(rs); }
catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); }
});
} catch (SQLException ex) {
send(s, NamedTextColor.RED, "Database error: " + ex.getMessage());
}
});
}
/** Overload for block queries with 3 coordinates. */
private void runQuery(CommandSender s, String sql,
int bx, int by, int bz, int page, RsConsumer consumer) {
int offset = (page - 1) * PAGE_SIZE;
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try (Connection con = plugin.getDb().getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setInt(1, bx);
ps.setInt(2, by);
ps.setInt(3, bz);
ps.setInt(4, PAGE_SIZE);
ps.setInt(5, offset);
ResultSet rs = ps.executeQuery();
plugin.getServer().getScheduler().runTask(plugin, () -> {
try { consumer.accept(rs); }
catch (SQLException ex) { send(s, NamedTextColor.RED, "Database error: " + ex.getMessage()); }
});
} catch (SQLException ex) {
send(s, NamedTextColor.RED, "Database error: " + ex.getMessage());
}
});
}
// --------------------------------------------------------
// Helper methods
// --------------------------------------------------------
private void requirePerm(CommandSender s, String perm, Runnable action) {
if (!s.hasPermission(perm)) {
send(s, NamedTextColor.RED, "No permission: " + perm);
} else {
action.run();
}
}
private int parsePage(String[] args, int idx) {
if (args.length > idx) {
try { return Math.max(1, Integer.parseInt(args[idx])); }
catch (NumberFormatException ignored) {}
}
return 1;
}
private static final DateTimeFormatter EU_TS = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss");
private static final DateTimeFormatter DB_TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private String fmtTs(String raw) {
if (raw == null) return "-";
try {
String s = raw.contains(".") ? raw.substring(0, raw.indexOf('.')) : raw;
return LocalDateTime.parse(s, DB_TS).format(EU_TS);
} catch (Exception e) {
return raw;
}
}
private String formatDuration(long seconds) {
long h = seconds / 3600, m = (seconds % 3600) / 60, sec = seconds % 60;
return String.format("%02d:%02d:%02d", h, m, sec);
}
private void sendHeader(CommandSender s, String title) {
s.sendMessage(Component.text("══ " + title + " ══")
.color(NamedTextColor.GOLD)
.decorate(TextDecoration.BOLD));
}
private void sendLine(CommandSender s, String cmd, String desc) {
s.sendMessage(Component.text(" " + cmd + " ")
.color(NamedTextColor.YELLOW)
.append(Component.text(" " + desc).color(NamedTextColor.GRAY)));
}
private void send(CommandSender s, NamedTextColor color, String text) {
s.sendMessage(Component.text(text).color(color));
}
}

View File

@@ -0,0 +1,877 @@
package de.simolzimol.mclogger.paper.database;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import org.bukkit.configuration.file.FileConfiguration;
import java.sql.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
/**
* Manages the MariaDB connection pool and provides
* helper methods for writing all log events.
*
* @author SimolZimol
*/
public class DatabaseManager {
private final PaperLoggerPlugin plugin;
private HikariDataSource dataSource;
private String serverName;
private int serverId = -1;
private static final Gson GSON = new GsonBuilder().create();
public DatabaseManager(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
// --------------------------------------------------------
// Connection Lifecycle
// --------------------------------------------------------
public boolean connect() {
FileConfiguration cfg = plugin.getConfig();
serverName = cfg.getString("server.name", "default");
HikariConfig hk = new HikariConfig();
hk.setDriverClassName("de.simolzimol.mclogger.lib.mariadb.jdbc.Driver");
hk.setJdbcUrl(String.format("jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
cfg.getString("database.host", "localhost"),
cfg.getInt("database.port", 3306),
cfg.getString("database.database", "mclogger"),
cfg.getBoolean("database.ssl", false)));
hk.setUsername(cfg.getString("database.username", "root"));
hk.setPassword(cfg.getString("database.password", ""));
hk.setMaximumPoolSize(cfg.getInt("database.pool-size", 10));
hk.setMinimumIdle(2);
hk.setConnectionTimeout(30_000);
hk.setIdleTimeout(600_000);
hk.setMaxLifetime(1_800_000);
hk.setPoolName("MCLogger-Paper");
hk.addDataSourceProperty("cachePrepStmts", "true");
hk.addDataSourceProperty("prepStmtCacheSize", "250");
hk.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
try {
dataSource = new HikariDataSource(hk);
initializeTables();
registerServer(cfg);
plugin.getLogger().info("[MCLogger] Database connected - Server: " + serverName + " (ID: " + serverId + ")");
return true;
} catch (Exception e) {
plugin.getLogger().log(Level.SEVERE, "[MCLogger] Database connection failed!", e);
return false;
}
}
private void initializeTables() throws SQLException {
String[] ddl = {
// servers
"CREATE TABLE IF NOT EXISTS servers (" +
" id INT AUTO_INCREMENT PRIMARY KEY," +
" server_name VARCHAR(100) NOT NULL," +
" server_type ENUM('paper','velocity') NOT NULL DEFAULT 'paper'," +
" ip_address VARCHAR(45)," +
" mc_version VARCHAR(100)," +
" first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)," +
" UNIQUE KEY uq_server_name (server_name)" +
") ENGINE=InnoDB",
// players
"CREATE TABLE IF NOT EXISTS players (" +
" uuid VARCHAR(36) PRIMARY KEY," +
" username VARCHAR(16) NOT NULL," +
" display_name VARCHAR(64)," +
" ip_address VARCHAR(45)," +
" locale VARCHAR(20)," +
" first_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" last_seen TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)," +
" total_playtime_sec BIGINT DEFAULT 0," +
" is_op TINYINT(1) DEFAULT 0," +
" INDEX idx_username (username)" +
") ENGINE=InnoDB",
// player_sessions
"CREATE TABLE IF NOT EXISTS player_sessions (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" player_uuid VARCHAR(36) NOT NULL," +
" player_name VARCHAR(16) NOT NULL," +
" server_name VARCHAR(100)," +
" login_time TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" logout_time TIMESTAMP(3) NULL DEFAULT NULL," +
" duration_sec INT," +
" ip_address VARCHAR(45)," +
" country VARCHAR(100)," +
" client_version VARCHAR(20)," +
" INDEX idx_ps_uuid (player_uuid)," +
" INDEX idx_ps_server (server_name)," +
" INDEX idx_ps_login (login_time)" +
") ENGINE=InnoDB",
// player_chat
"CREATE TABLE IF NOT EXISTS player_chat (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" message TEXT NOT NULL," +
" channel VARCHAR(50) DEFAULT 'global'," +
" INDEX idx_chat_uuid (player_uuid)," +
" INDEX idx_chat_timestamp (timestamp)," +
" INDEX idx_chat_server (server_name)," +
" FULLTEXT INDEX ft_message (message)" +
") ENGINE=InnoDB",
// player_commands
"CREATE TABLE IF NOT EXISTS player_commands (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" command TEXT NOT NULL," +
" x DOUBLE," +
" y DOUBLE," +
" z DOUBLE," +
" INDEX idx_cmd_uuid (player_uuid)," +
" INDEX idx_cmd_timestamp (timestamp)," +
" INDEX idx_cmd_server (server_name)" +
") ENGINE=InnoDB",
// block_events
"CREATE TABLE IF NOT EXISTS block_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type ENUM('break','place','ignite','burn','explode','fade','grow','dispense') NOT NULL," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" server_name VARCHAR(100)," +
" world VARCHAR(100) NOT NULL," +
" x INT NOT NULL," +
" y INT NOT NULL," +
" z INT NOT NULL," +
" block_type VARCHAR(100) NOT NULL," +
" block_data VARCHAR(255)," +
" tool VARCHAR(100)," +
" is_silk TINYINT(1) DEFAULT 0," +
" INDEX idx_be_player (player_uuid)," +
" INDEX idx_be_timestamp (timestamp)," +
" INDEX idx_be_world (world)," +
" INDEX idx_be_type (event_type)," +
" INDEX idx_be_server (server_name)" +
") ENGINE=InnoDB",
// player_deaths
"CREATE TABLE IF NOT EXISTS player_deaths (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" player_uuid VARCHAR(36) NOT NULL," +
" player_name VARCHAR(16) NOT NULL," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" x DOUBLE," +
" y DOUBLE," +
" z DOUBLE," +
" death_message TEXT," +
" cause VARCHAR(100)," +
" killer_uuid VARCHAR(36)," +
" killer_name VARCHAR(100)," +
" killer_type VARCHAR(100)," +
" exp_level INT," +
" items_lost JSON," +
" INDEX idx_deaths_uuid (player_uuid)," +
" INDEX idx_deaths_timestamp (timestamp)" +
") ENGINE=InnoDB",
// entity_events
"CREATE TABLE IF NOT EXISTS entity_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type ENUM('spawn','death','damage','tame','breed','transform','explode') NOT NULL," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" x DOUBLE," +
" y DOUBLE," +
" z DOUBLE," +
" entity_type VARCHAR(100) NOT NULL," +
" entity_uuid VARCHAR(36)," +
" entity_name VARCHAR(100)," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" cause VARCHAR(100)," +
" damage DOUBLE," +
" details JSON," +
" INDEX idx_ee_timestamp (timestamp)," +
" INDEX idx_ee_type (event_type)," +
" INDEX idx_ee_entity (entity_type)," +
" INDEX idx_ee_player (player_uuid)," +
" INDEX idx_ee_server (server_name)" +
") ENGINE=InnoDB",
// player_teleports
"CREATE TABLE IF NOT EXISTS player_teleports (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" player_uuid VARCHAR(36) NOT NULL," +
" player_name VARCHAR(16) NOT NULL," +
" server_name VARCHAR(100)," +
" from_world VARCHAR(100)," +
" from_x DOUBLE," +
" from_y DOUBLE," +
" from_z DOUBLE," +
" to_world VARCHAR(100)," +
" to_x DOUBLE," +
" to_y DOUBLE," +
" to_z DOUBLE," +
" cause VARCHAR(100)," +
" INDEX idx_tp_uuid (player_uuid)," +
" INDEX idx_tp_timestamp (timestamp)" +
") ENGINE=InnoDB",
// inventory_events
"CREATE TABLE IF NOT EXISTS inventory_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type ENUM('pickup','drop','click','craft','enchant','anvil','trade') NOT NULL," +
" player_uuid VARCHAR(36) NOT NULL," +
" player_name VARCHAR(16) NOT NULL," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" x DOUBLE," +
" y DOUBLE," +
" z DOUBLE," +
" item_type VARCHAR(100)," +
" item_amount INT," +
" item_meta JSON," +
" slot INT," +
" inventory_type VARCHAR(100)," +
" INDEX idx_inv_uuid (player_uuid)," +
" INDEX idx_inv_timestamp (timestamp)," +
" INDEX idx_inv_type (event_type)" +
") ENGINE=InnoDB",
// player_stats
"CREATE TABLE IF NOT EXISTS player_stats (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type VARCHAR(100) NOT NULL," +
" player_uuid VARCHAR(36) NOT NULL," +
" player_name VARCHAR(16) NOT NULL," +
" server_name VARCHAR(100)," +
" old_value VARCHAR(255)," +
" new_value VARCHAR(255)," +
" details JSON," +
" INDEX idx_pst_uuid (player_uuid)," +
" INDEX idx_pst_timestamp (timestamp)," +
" INDEX idx_pst_type (event_type)" +
") ENGINE=InnoDB",
// world_events
"CREATE TABLE IF NOT EXISTS world_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type VARCHAR(100) NOT NULL," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" x DOUBLE," +
" y DOUBLE," +
" z DOUBLE," +
" details JSON," +
" INDEX idx_we_timestamp (timestamp)," +
" INDEX idx_we_world (world)," +
" INDEX idx_we_type (event_type)" +
") ENGINE=InnoDB",
// server_events
"CREATE TABLE IF NOT EXISTS server_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type VARCHAR(100) NOT NULL," +
" server_name VARCHAR(100)," +
" message TEXT," +
" details JSON," +
" INDEX idx_se_timestamp (timestamp)," +
" INDEX idx_se_type (event_type)," +
" INDEX idx_se_server (server_name)" +
") ENGINE=InnoDB",
// proxy_events
"CREATE TABLE IF NOT EXISTS proxy_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type ENUM('login','disconnect','server_switch','command','chat','kick','proxy_start','proxy_stop') NOT NULL," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" proxy_name VARCHAR(100)," +
" from_server VARCHAR(100)," +
" to_server VARCHAR(100)," +
" ip_address VARCHAR(45)," +
" details JSON," +
" INDEX idx_pe_uuid (player_uuid)," +
" INDEX idx_pe_timestamp (timestamp)," +
" INDEX idx_pe_type (event_type)," +
" INDEX idx_pe_proxy (proxy_name)" +
") ENGINE=InnoDB",
// sign_edits
"CREATE TABLE IF NOT EXISTS sign_edits (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" server_name VARCHAR(100)," +
" world VARCHAR(100)," +
" x INT," +
" y INT," +
" z INT," +
" line1 VARCHAR(255)," +
" line2 VARCHAR(255)," +
" line3 VARCHAR(255)," +
" line4 VARCHAR(255)," +
" INDEX idx_sign_uuid (player_uuid)," +
" INDEX idx_sign_timestamp (timestamp)" +
") ENGINE=InnoDB",
// plugin_events
"CREATE TABLE IF NOT EXISTS plugin_events (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" timestamp TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" event_type VARCHAR(100) NOT NULL," +
" plugin_name VARCHAR(100)," +
" server_name VARCHAR(100)," +
" player_uuid VARCHAR(36)," +
" player_name VARCHAR(16)," +
" actor_uuid VARCHAR(36)," +
" actor_name VARCHAR(64)," +
" target_type VARCHAR(50)," +
" target_id VARCHAR(100)," +
" action VARCHAR(255)," +
" details JSON," +
" INDEX idx_plev_uuid (player_uuid)," +
" INDEX idx_plev_timestamp (timestamp)," +
" INDEX idx_plev_type (event_type)," +
" INDEX idx_plev_plugin (plugin_name)," +
" INDEX idx_plev_actor (actor_uuid)" +
") ENGINE=InnoDB",
// view
"CREATE OR REPLACE VIEW v_recent_activity AS" +
" SELECT 'chat' AS source, timestamp, player_name, server_name, message AS detail FROM player_chat WHERE timestamp >= NOW() - INTERVAL 24 HOUR" +
" UNION ALL" +
" SELECT 'command' AS source, timestamp, player_name, server_name, command AS detail FROM player_commands WHERE timestamp >= NOW() - INTERVAL 24 HOUR" +
" UNION ALL" +
" SELECT 'block' AS source, timestamp, player_name, server_name, CONCAT(event_type,' ',block_type,' at ',world,' ',x,',',y,',',z) AS detail FROM block_events WHERE timestamp >= NOW() - INTERVAL 24 HOUR" +
" UNION ALL" +
" SELECT 'death' AS source, timestamp, player_name, server_name, death_message AS detail FROM player_deaths WHERE timestamp >= NOW() - INTERVAL 24 HOUR" +
" ORDER BY timestamp DESC"
};
try (Connection con = dataSource.getConnection();
Statement st = con.createStatement()) {
for (String sql : ddl) {
st.execute(sql);
}
// Widen mc_version in case the table was created with the old VARCHAR(20)
st.execute("ALTER TABLE servers MODIFY COLUMN mc_version VARCHAR(100)");
// Add country column to existing tables
st.execute("ALTER TABLE player_sessions ADD COLUMN IF NOT EXISTS country VARCHAR(100) AFTER ip_address");
}
plugin.getLogger().info("[MCLogger] Database tables verified/created.");
}
public void disconnect() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
private void registerServer(FileConfiguration cfg) throws SQLException {
String version = plugin.getServer().getMinecraftVersion();
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO servers (server_name, server_type, ip_address, mc_version) VALUES (?,?,?,?) " +
"ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP(3), mc_version = VALUES(mc_version)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, serverName);
ps.setString(2, "paper");
ps.setString(3, cfg.getString("database.host", "localhost"));
ps.setString(4, version);
ps.executeUpdate();
try (ResultSet rs = ps.getGeneratedKeys()) {
if (rs.next()) serverId = rs.getInt(1);
}
}
if (serverId == -1) {
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT id FROM servers WHERE server_name = ?")) {
ps.setString(1, serverName);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) serverId = rs.getInt(1);
}
}
}
}
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
// --------------------------------------------------------
// Async insert helper
// --------------------------------------------------------
private void asyncExec(ThrowingRunnable r) {
CompletableFuture.runAsync(() -> {
try {
r.run();
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "[MCLogger] DB write error: " + e.getMessage());
}
});
}
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
// --------------------------------------------------------
// Players
// --------------------------------------------------------
public void upsertPlayer(String uuid, String username, String displayName,
String ip, String locale, boolean isOp) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO players (uuid, username, display_name, ip_address, locale, is_op) VALUES (?,?,?,?,?,?) " +
"ON DUPLICATE KEY UPDATE username=VALUES(username), display_name=VALUES(display_name), " +
"ip_address=VALUES(ip_address), locale=VALUES(locale), is_op=VALUES(is_op), " +
"last_seen=CURRENT_TIMESTAMP(3)")) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, displayName);
ps.setString(4, ip);
ps.setString(5, locale);
ps.setBoolean(6, isOp);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Sessions
// --------------------------------------------------------
public void insertSessionLogin(String uuid, String name, String ip, String clientVersion) {
asyncExec(() -> {
String country = lookupCountry(ip);
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_sessions (player_uuid, player_name, server_name, ip_address, country, client_version) VALUES (?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, ip);
ps.setString(5, country);
ps.setString(6, clientVersion);
ps.executeUpdate();
}
});
}
/** Looks up the player's country via ip-api.com (free, no API key). Returns "Local" for LAN/private IPs. */
private String lookupCountry(String ip) {
if (ip == null || ip.equals("unknown") || ip.equals("::1")
|| ip.startsWith("127.") || ip.startsWith("10.")
|| ip.startsWith("192.168.") || ip.startsWith("172.")) {
return "Local";
}
try {
java.net.URL url = new java.net.URL("http://ip-api.com/json/" + ip + "?fields=country");
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(3000);
conn.setRequestMethod("GET");
try (java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) sb.append(line);
JsonObject obj = GSON.fromJson(sb.toString(), JsonObject.class);
if (obj != null && obj.has("country")) return obj.get("country").getAsString();
}
} catch (Exception ignored) {}
return "Unknown";
}
public void closeSession(String uuid, long durationSec) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"UPDATE player_sessions SET logout_time=CURRENT_TIMESTAMP(3), duration_sec=? " +
"WHERE player_uuid=? AND server_name=? AND logout_time IS NULL ORDER BY login_time DESC LIMIT 1")) {
ps.setLong(1, durationSec);
ps.setString(2, uuid);
ps.setString(3, serverName);
ps.executeUpdate();
}
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"UPDATE players SET total_playtime_sec = total_playtime_sec + ? WHERE uuid = ?")) {
ps.setLong(1, durationSec);
ps.setString(2, uuid);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Chat
// --------------------------------------------------------
public void insertChat(String uuid, String name, String world, String message, String channel) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_chat (player_uuid, player_name, server_name, world, message, channel) VALUES (?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, world);
ps.setString(5, message);
ps.setString(6, channel);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Commands
// --------------------------------------------------------
public void insertCommand(String uuid, String name, String world,
String command, double x, double y, double z) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_commands (player_uuid, player_name, server_name, world, command, x, y, z) VALUES (?,?,?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, world);
ps.setString(5, command);
ps.setDouble(6, x);
ps.setDouble(7, y);
ps.setDouble(8, z);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Block Events
// --------------------------------------------------------
public void insertBlockEvent(String type, String uuid, String name,
String world, int x, int y, int z,
String blockType, String blockData, String tool, boolean silkTouch) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO block_events (event_type, player_uuid, player_name, server_name, world, x, y, z, block_type, block_data, tool, is_silk) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, uuid);
ps.setString(3, name);
ps.setString(4, serverName);
ps.setString(5, world);
ps.setInt(6, x);
ps.setInt(7, y);
ps.setInt(8, z);
ps.setString(9, blockType);
ps.setString(10, blockData);
ps.setString(11, tool);
ps.setBoolean(12, silkTouch);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Deaths
// --------------------------------------------------------
public void insertDeath(String uuid, String name, String world, double x, double y, double z,
String msg, String cause, String killerUuid, String killerName,
String killerType, int expLevel, Map<String, Object> itemsLost) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_deaths (player_uuid, player_name, server_name, world, x, y, z, " +
"death_message, cause, killer_uuid, killer_name, killer_type, exp_level, items_lost) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, world);
ps.setDouble(5, x);
ps.setDouble(6, y);
ps.setDouble(7, z);
ps.setString(8, msg);
ps.setString(9, cause);
ps.setString(10, killerUuid);
ps.setString(11, killerName);
ps.setString(12, killerType);
ps.setInt(13, expLevel);
ps.setString(14, itemsLost != null ? GSON.toJson(itemsLost) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Entity Events
// --------------------------------------------------------
public void insertEntityEvent(String type, String world, double x, double y, double z,
String entityType, String entityUuid, String entityName,
String playerUuid, String playerName, String cause,
double damage, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO entity_events (event_type, server_name, world, x, y, z, entity_type, entity_uuid, entity_name, player_uuid, player_name, cause, damage, details) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, serverName);
ps.setString(3, world);
ps.setDouble(4, x);
ps.setDouble(5, y);
ps.setDouble(6, z);
ps.setString(7, entityType);
ps.setString(8, entityUuid);
ps.setString(9, entityName);
ps.setString(10, playerUuid);
ps.setString(11, playerName);
ps.setString(12, cause);
ps.setDouble(13, damage);
ps.setString(14, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Teleports
// --------------------------------------------------------
public void insertTeleport(String uuid, String name,
String fromWorld, double fx, double fy, double fz,
String toWorld, double tx, double ty, double tz,
String cause) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_teleports (player_uuid, player_name, server_name, from_world, from_x, from_y, from_z, to_world, to_x, to_y, to_z, cause) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, fromWorld);
ps.setDouble(5, fx);
ps.setDouble(6, fy);
ps.setDouble(7, fz);
ps.setString(8, toWorld);
ps.setDouble(9, tx);
ps.setDouble(10, ty);
ps.setDouble(11, tz);
ps.setString(12, cause);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Inventory Events
// --------------------------------------------------------
public void insertInventoryEvent(String type, String uuid, String name,
String world, double x, double y, double z,
String item, int amount, Map<String, Object> meta,
int slot, String invType) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO inventory_events (event_type, player_uuid, player_name, server_name, world, x, y, z, item_type, item_amount, item_meta, slot, inventory_type) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, uuid);
ps.setString(3, name);
ps.setString(4, serverName);
ps.setString(5, world);
ps.setDouble(6, x);
ps.setDouble(7, y);
ps.setDouble(8, z);
ps.setString(9, item);
ps.setInt(10, amount);
ps.setString(11, meta != null ? GSON.toJson(meta) : null);
ps.setInt(12, slot);
ps.setString(13, invType);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Player Stats (Gamemode, Level, etc.)
// --------------------------------------------------------
public void insertPlayerStat(String type, String uuid, String name,
String oldVal, String newVal, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_stats (event_type, player_uuid, player_name, server_name, old_value, new_value, details) VALUES (?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, uuid);
ps.setString(3, name);
ps.setString(4, serverName);
ps.setString(5, oldVal);
ps.setString(6, newVal);
ps.setString(7, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// World Events
// --------------------------------------------------------
public void insertWorldEvent(String type, String world, Double x, Double y, Double z, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO world_events (event_type, server_name, world, x, y, z, details) VALUES (?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, serverName);
ps.setString(3, world);
if (x != null) ps.setDouble(4, x); else ps.setNull(4, Types.DOUBLE);
if (y != null) ps.setDouble(5, y); else ps.setNull(5, Types.DOUBLE);
if (z != null) ps.setDouble(6, z); else ps.setNull(6, Types.DOUBLE);
ps.setString(7, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Server Events
// --------------------------------------------------------
public void insertServerEvent(String type, String message, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO server_events (event_type, server_name, message, details) VALUES (?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, serverName);
ps.setString(3, message);
ps.setString(4, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Sign Edits
// --------------------------------------------------------
public void insertSignEdit(String uuid, String name, String world,
int x, int y, int z,
String l1, String l2, String l3, String l4) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO sign_edits (player_uuid, player_name, server_name, world, x, y, z, line1, line2, line3, line4) VALUES (?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, world);
ps.setInt(5, x);
ps.setInt(6, y);
ps.setInt(7, z);
ps.setString(8, l1);
ps.setString(9, l2);
ps.setString(10, l3);
ps.setString(11, l4);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Plugin Events (LuckPerms etc.)
// --------------------------------------------------------
/**
* Writes a generic plugin event (e.g. LuckPerms permission change).
*
* @param eventType Type of event (e.g. "luckperms_grant", "luckperms_revoke")
* @param pluginName Name of the triggering plugin (e.g. "LuckPerms")
* @param playerUuid UUID of the affected player (may be null)
* @param playerName Name of the affected player
* @param actorUuid UUID of the actor (console UUID = 00000000-...)
* @param actorName Display name of the actor
* @param targetType "user" or "group"
* @param targetId UUID or group name
* @param action Description of the action (e.g. "permission.node set to true")
* @param details Additional details as Map
*/
public void insertPluginEvent(String eventType, String pluginName,
String playerUuid, String playerName,
String actorUuid, String actorName,
String targetType, String targetId,
String action, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO plugin_events " +
"(event_type, plugin_name, server_name, player_uuid, player_name, " +
" actor_uuid, actor_name, target_type, target_id, action, details) " +
"VALUES (?,?,?,?,?,?,?,?,?,?,?)")) {
ps.setString(1, eventType);
ps.setString(2, pluginName);
ps.setString(3, serverName);
if (playerUuid != null) ps.setString(4, playerUuid); else ps.setNull(4, Types.VARCHAR);
if (playerName != null) ps.setString(5, playerName); else ps.setNull(5, Types.VARCHAR);
if (actorUuid != null) ps.setString(6, actorUuid); else ps.setNull(6, Types.VARCHAR);
if (actorName != null) ps.setString(7, actorName); else ps.setNull(7, Types.VARCHAR);
if (targetType != null) ps.setString(8, targetType); else ps.setNull(8, Types.VARCHAR);
if (targetId != null) ps.setString(9, targetId); else ps.setNull(9, Types.VARCHAR);
if (action != null) ps.setString(10, action); else ps.setNull(10, Types.VARCHAR);
ps.setString(11, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Getter
// --------------------------------------------------------
public String getServerName() { return serverName; }
public int getServerId() { return serverId; }
public boolean isConnected() { return dataSource != null && !dataSource.isClosed(); }
}

View File

@@ -0,0 +1,162 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.block.Block;
import org.bukkit.block.Sign;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.*;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.HashMap;
import java.util.Map;
/**
* Logs all block-related events: breaking, placing,
* burning, exploding, igniting, signs, etc.
*
* @author SimolZimol
*/
public class BlockListener implements Listener {
private final PaperLoggerPlugin plugin;
public BlockListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
// -------------------------------------------------------
// Block Break
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
Player p = event.getPlayer();
Block b = event.getBlock();
ItemStack tool = p.getInventory().getItemInMainHand();
boolean silkTouch = tool.hasItemMeta()
&& tool.getItemMeta() != null
&& tool.getItemMeta().hasEnchant(Enchantment.SILK_TOUCH);
String toolName = tool.getType().isAir() ? "HAND" : tool.getType().name();
String blockData = b.getBlockData().getAsString();
plugin.getDb().insertBlockEvent(
"break",
p.getUniqueId().toString(),
p.getName(),
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
b.getType().name(),
blockData,
toolName,
silkTouch
);
}
// -------------------------------------------------------
// Block Place
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
Player p = event.getPlayer();
Block b = event.getBlockPlaced();
plugin.getDb().insertBlockEvent(
"place",
p.getUniqueId().toString(),
p.getName(),
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
b.getType().name(),
b.getBlockData().getAsString(),
null, false
);
}
// -------------------------------------------------------
// Block Burn
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBurn(BlockBurnEvent event) {
Block b = event.getBlock();
plugin.getDb().insertBlockEvent(
"burn",
null, null,
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
b.getType().name(),
b.getBlockData().getAsString(),
null, false
);
}
// -------------------------------------------------------
// Block Ignite
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onIgnite(BlockIgniteEvent event) {
Block b = event.getBlock();
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("cause", event.getCause().name());
plugin.getDb().insertBlockEvent(
"ignite",
p != null ? p.getUniqueId().toString() : null,
p != null ? p.getName() : null,
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
b.getType().name(),
b.getBlockData().getAsString(),
null, false
);
}
// -------------------------------------------------------
// Block Explosion
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onExplode(BlockExplodeEvent event) {
Block b = event.getBlock();
plugin.getDb().insertBlockEvent(
"explode",
null, null,
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
b.getType().name(),
b.getBlockData().getAsString(),
null, false
);
}
// -------------------------------------------------------
// Sign Edit
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSign(SignChangeEvent event) {
Player p = event.getPlayer();
Block b = event.getBlock();
String[] lines = new String[4];
for (int i = 0; i < 4; i++) {
lines[i] = event.line(i) != null
? PlainTextComponentSerializer.plainText().serialize(event.line(i))
: "";
}
plugin.getDb().insertSignEdit(
p.getUniqueId().toString(),
p.getName(),
b.getWorld().getName(),
b.getX(), b.getY(), b.getZ(),
lines[0], lines[1], lines[2], lines[3]
);
}
}

View File

@@ -0,0 +1,206 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.entity.*;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.*;
import java.util.HashMap;
import java.util.Map;
/**
* Logs entity events: spawns, deaths, damage, taming, breeding, explosions.
*
* @author SimolZimol
*/
public class EntityListener implements Listener {
private final PaperLoggerPlugin plugin;
public EntityListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
// -------------------------------------------------------
// Entity Spawn
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSpawn(EntitySpawnEvent event) {
Entity e = event.getEntity();
// Skip redundant entities (projectiles etc.) to reduce DB load
if (e instanceof Item || e instanceof ExperienceOrb || e instanceof Arrow) return;
String playerUuid = null, playerName = null;
if (e instanceof Player p) {
playerUuid = p.getUniqueId().toString();
playerName = p.getName();
}
Map<String, Object> details = new HashMap<>();
details.put("spawn_reason", e instanceof LivingEntity le ? "natural" : "other");
details.put("has_ai", e instanceof Mob mob && mob.hasAI());
plugin.getDb().insertEntityEvent(
"spawn",
e.getWorld().getName(),
e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(),
e.getType().name(),
e.getUniqueId().toString(),
e.customName() != null ? PlainTextComponentSerializer.plainText().serialize(e.customName()) : null,
playerUuid, playerName,
null, 0, details
);
}
// -------------------------------------------------------
// Entity Death
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDeath(EntityDeathEvent event) {
LivingEntity e = event.getEntity();
if (e instanceof Player) return; // Player deaths are handled by PlayerDeathListener
Player killer = e.getKiller();
String killerUuid = killer != null ? killer.getUniqueId().toString() : null;
String killerName = killer != null ? killer.getName() : null;
String cause = e.getLastDamageCause() != null
? e.getLastDamageCause().getCause().name() : "UNKNOWN";
Map<String, Object> details = new HashMap<>();
details.put("drops", event.getDrops().size());
details.put("exp", event.getDroppedExp());
plugin.getDb().insertEntityEvent(
"death",
e.getWorld().getName(),
e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(),
e.getType().name(),
e.getUniqueId().toString(),
e.customName() != null ? PlainTextComponentSerializer.plainText().serialize(e.customName()) : null,
killerUuid, killerName,
cause, 0, details
);
}
// -------------------------------------------------------
// Entity Damage (player-inflicted only)
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Player p)) return;
if (event.getEntity() instanceof Player) return; // PvP separat nicht nötig
Entity target = event.getEntity();
plugin.getDb().insertEntityEvent(
"damage",
target.getWorld().getName(),
target.getLocation().getX(), target.getLocation().getY(), target.getLocation().getZ(),
target.getType().name(),
target.getUniqueId().toString(),
target.customName() != null ? PlainTextComponentSerializer.plainText().serialize(target.customName()) : null,
p.getUniqueId().toString(),
p.getName(),
event.getCause().name(),
event.getFinalDamage(),
null
);
}
// -------------------------------------------------------
// Entity Tame
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTame(EntityTameEvent event) {
if (!(event.getOwner() instanceof Player p)) return;
Entity e = event.getEntity();
plugin.getDb().insertEntityEvent(
"tame",
e.getWorld().getName(),
e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(),
e.getType().name(),
e.getUniqueId().toString(),
null,
p.getUniqueId().toString(),
p.getName(),
null, 0, null
);
}
// -------------------------------------------------------
// Entity Breed
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBreed(EntityBreedEvent event) {
Entity e = event.getEntity();
Player breeder = event.getBreeder() instanceof Player pl ? pl : null;
Map<String, Object> details = new HashMap<>();
details.put("mother", event.getMother().getType().name());
details.put("father", event.getFather().getType().name());
if (event.getBredWith() != null) details.put("bred_with", event.getBredWith().getType().name());
plugin.getDb().insertEntityEvent(
"breed",
e.getWorld().getName(),
e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(),
e.getType().name(),
e.getUniqueId().toString(),
null,
breeder != null ? breeder.getUniqueId().toString() : null,
breeder != null ? breeder.getName() : null,
null, 0, details
);
}
// -------------------------------------------------------
// Explosion (Creeper, TNT, etc.)
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onExplode(EntityExplodeEvent event) {
Entity e = event.getEntity();
Map<String, Object> details = new HashMap<>();
details.put("blocks_destroyed", event.blockList().size());
details.put("yield", event.getYield());
plugin.getDb().insertEntityEvent(
"explode",
e.getWorld().getName(),
e.getLocation().getX(), e.getLocation().getY(), e.getLocation().getZ(),
e.getType().name(),
e.getUniqueId().toString(),
null,
null, null,
null, 0, details
);
}
// -------------------------------------------------------
// PvP Damage between players
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPvP(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player victim)) return;
if (!(event.getDamager() instanceof Player attacker)) return;
Map<String, Object> details = new HashMap<>();
details.put("cause", event.getCause().name());
details.put("damage", event.getFinalDamage());
details.put("victim_health_after",
Math.max(0, victim.getHealth() - event.getFinalDamage()));
plugin.getDb().insertPlayerStat(
"pvp_hit",
attacker.getUniqueId().toString(),
attacker.getName(),
attacker.getName(),
victim.getName(),
details
);
}
}

View File

@@ -0,0 +1,124 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.inventory.*;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.HashMap;
import java.util.Map;
/**
* Logs inventory events: item pickup, item drop,
* crafting, enchanting, trading, etc.
*
* @author SimolZimol
*/
public class InventoryListener implements Listener {
private final PaperLoggerPlugin plugin;
public InventoryListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
// -------------------------------------------------------
// Item Pickup
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player p)) return;
ItemStack item = event.getItem().getItemStack();
plugin.getDb().insertInventoryEvent(
"pickup",
p.getUniqueId().toString(), p.getName(),
p.getWorld().getName(),
p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(),
item.getType().name(), item.getAmount(),
buildItemMeta(item), -1,
"player"
);
}
// -------------------------------------------------------
// Item Drop
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDrop(PlayerDropItemEvent event) {
Player p = event.getPlayer();
ItemStack item = event.getItemDrop().getItemStack();
plugin.getDb().insertInventoryEvent(
"drop",
p.getUniqueId().toString(), p.getName(),
p.getWorld().getName(),
p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(),
item.getType().name(), item.getAmount(),
buildItemMeta(item), -1,
"player"
);
}
// -------------------------------------------------------
// Inventory Clicks (player inventories only)
// -------------------------------------------------------
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player p)) return;
if (event.getCurrentItem() == null) return;
// Only crafting, enchanting, anvil -> trade, etc.
InventoryType type = event.getInventory().getType();
if (type == InventoryType.PLAYER || type == InventoryType.CREATIVE) return; // too much noise
ItemStack item = event.getCurrentItem();
String invTypeName = type.name();
String logType = switch (type) {
case CRAFTING, WORKBENCH -> "craft";
case ENCHANTING -> "enchant";
case ANVIL -> "anvil";
case MERCHANT -> "trade";
default -> "click";
};
plugin.getDb().insertInventoryEvent(
logType,
p.getUniqueId().toString(), p.getName(),
p.getWorld().getName(),
p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ(),
item.getType().name(), item.getAmount(),
buildItemMeta(item),
event.getSlot(),
invTypeName
);
}
// -------------------------------------------------------
// Helper method: item meta as Map
// -------------------------------------------------------
private Map<String, Object> buildItemMeta(ItemStack item) {
if (!item.hasItemMeta()) return null;
ItemMeta meta = item.getItemMeta();
Map<String, Object> m = new HashMap<>();
if (meta.hasDisplayName() && meta.displayName() != null) {
m.put("name", PlainTextComponentSerializer.plainText().serialize(meta.displayName()));
}
if (meta.hasEnchants()) {
Map<String, Integer> enc = new HashMap<>();
meta.getEnchants().forEach((e, lvl) -> enc.put(e.getKey().getKey(), lvl));
m.put("enchants", enc);
}
if (meta.hasLore() && meta.lore() != null) {
m.put("lore_lines", meta.lore().size());
}
if (meta.isUnbreakable()) m.put("unbreakable", true);
return m.isEmpty() ? null : m;
}
}

View File

@@ -0,0 +1,125 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.event.EventBus;
import net.luckperms.api.event.log.LogPublishEvent;
import net.luckperms.api.actionlog.Action;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Listens for LuckPerms log events and stores all permission changes.
* Only registered when LuckPerms is loaded (softdepend).
*
* @author SimolZimol
*/
public class LuckPermsListener {
private final PaperLoggerPlugin plugin;
public LuckPermsListener(PaperLoggerPlugin plugin, LuckPerms luckPerms) {
this.plugin = plugin;
registerEvents(luckPerms.getEventBus());
plugin.getLogger().info("[MCLogger] LuckPerms listener registered.");
}
private void registerEvents(EventBus bus) {
bus.subscribe(plugin, LogPublishEvent.class, this::onLogPublish);
}
/**
* LogPublishEvent is fired by LuckPerms whenever an action is written to
* the action log (grant, revoke, group create/delete, track
* add/remove, meta set, etc.).
*/
private void onLogPublish(LogPublishEvent event) {
Action entry = event.getEntry();
String actorUuid = entry.getSource().getUniqueId().toString();
String actorName = entry.getSource().getName();
String targetType = formatTargetType(entry.getTarget().getType());
String targetId = formatTargetId(entry.getTarget());
String actionStr = entry.getDescription();
// Derive player UUID if the target is a user
String playerUuid = null;
String playerName = null;
if (entry.getTarget().getType() == Action.Target.Type.USER) {
playerUuid = entry.getTarget().getUniqueId()
.map(UUID::toString).orElse(null);
playerName = entry.getTarget().getName();
}
// Derive event type from the action description
String eventType = deriveEventType(actionStr);
Map<String, Object> details = new HashMap<>();
details.put("actor_uuid", actorUuid);
details.put("actor_name", actorName);
details.put("target_type", targetType);
details.put("target_id", targetId);
details.put("action_raw", actionStr);
details.put("timestamp_luckperms", entry.getTimestamp().getEpochSecond());
plugin.getDb().insertPluginEvent(
eventType,
"LuckPerms",
playerUuid,
playerName,
actorUuid,
actorName,
targetType,
targetId,
actionStr,
details
);
}
// --------------------------------------------------------
// Helper methods
// --------------------------------------------------------
private String formatTargetType(Action.Target.Type type) {
return switch (type) {
case USER -> "user";
case GROUP -> "group";
case TRACK -> "track";
};
}
private String formatTargetId(Action.Target target) {
if (target.getUniqueId().isPresent()) {
return target.getUniqueId().get().toString();
}
return target.getName();
}
/**
* Maps an action description to a compact event type.
* Beispiele:
* "permission set permission.node to true" -> luckperms_permission_set
* "permission unset permission.node" -> luckperms_permission_unset
* "parent add groupname" -> luckperms_parent_add
* "group create groupname" -> luckperms_group_create
*/
private String deriveEventType(String action) {
if (action == null) return "luckperms_unknown";
String lower = action.toLowerCase();
if (lower.startsWith("permission set")) return "luckperms_permission_set";
if (lower.startsWith("permission unset")) return "luckperms_permission_unset";
if (lower.startsWith("parent add")) return "luckperms_parent_add";
if (lower.startsWith("parent remove")) return "luckperms_parent_remove";
if (lower.startsWith("meta set")) return "luckperms_meta_set";
if (lower.startsWith("meta unset")) return "luckperms_meta_unset";
if (lower.startsWith("group create")) return "luckperms_group_create";
if (lower.startsWith("group delete")) return "luckperms_group_delete";
if (lower.startsWith("track create")) return "luckperms_track_create";
if (lower.startsWith("track delete")) return "luckperms_track_delete";
if (lower.startsWith("track add")) return "luckperms_track_add";
if (lower.startsWith("track remove")) return "luckperms_track_remove";
return "luckperms_action";
}
}

View File

@@ -0,0 +1,69 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* Logs commands executed by players.
* Chat is logged by the Velocity proxy with correct server context.
*
* @author SimolZimol
*/
public class PlayerChatCommandListener implements Listener {
private final PaperLoggerPlugin plugin;
public PlayerChatCommandListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
/**
* Command logs with world/position, then notifies Velocity proxy.
* Velocity will skip its own fallback log once it receives the plugin message.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCommand(PlayerCommandPreprocessEvent event) {
Player p = event.getPlayer();
String cmd = event.getMessage(); // includes leading '/'
plugin.getDb().insertCommand(
p.getUniqueId().toString(),
p.getName(),
p.getWorld().getName(),
cmd,
p.getLocation().getX(),
p.getLocation().getY(),
p.getLocation().getZ()
);
// Notify Velocity proxy so it skips duplicate fallback logging
String key = p.getUniqueId() + "|" + cmd;
p.sendPluginMessage(plugin, "mclogger:logged", key.getBytes(StandardCharsets.UTF_8));
}
/**
* World change (e.g. Nether / End portal)
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldChange(PlayerChangedWorldEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("from_world", event.getFrom().getName());
details.put("to_world", p.getWorld().getName());
plugin.getDb().insertPlayerStat(
"world_change",
p.getUniqueId().toString(),
p.getName(),
event.getFrom().getName(),
p.getWorld().getName(),
details
);
}
}

View File

@@ -0,0 +1,106 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.EntityEffect;
import org.bukkit.entity.*;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.inventory.ItemStack;
import java.util.*;
/**
* Logs player deaths with full context:
* - cause of death, killer, lost items, XP level
*
* @author SimolZimol
*/
public class PlayerDeathListener implements Listener {
private final PaperLoggerPlugin plugin;
public PlayerDeathListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDeath(PlayerDeathEvent event) {
Player p = event.getPlayer();
String deathMsg = event.deathMessage() != null
? PlainTextComponentSerializer.plainText().serialize(event.deathMessage())
: "No death message";
// Cause of death
String cause = "UNKNOWN";
if (p.getLastDamageCause() != null) {
cause = p.getLastDamageCause().getCause().name();
}
// Determine killer
String killerUuid = null, killerName = null, killerType = null;
Entity killer = p.getKiller();
if (killer != null) {
killerType = killer.getType().name();
killerName = killer instanceof Player
? ((Player) killer).getName()
: (killer.customName() != null
? PlainTextComponentSerializer.plainText().serialize(killer.customName())
: killer.getType().name());
killerUuid = killer.getUniqueId().toString();
} else if (p.getLastDamageCause() != null) {
killerType = p.getLastDamageCause().getCause().name();
}
// Serialize lost items
Map<String, Object> itemsLost = new LinkedHashMap<>();
List<Map<String, Object>> items = new ArrayList<>();
for (ItemStack item : event.getDrops()) {
if (item == null) continue;
Map<String, Object> itemMap = new HashMap<>();
itemMap.put("type", item.getType().name());
itemMap.put("amount", item.getAmount());
if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) {
itemMap.put("name", PlainTextComponentSerializer.plainText()
.serialize(item.getItemMeta().displayName()));
}
items.add(itemMap);
}
itemsLost.put("items", items);
plugin.getDb().insertDeath(
p.getUniqueId().toString(),
p.getName(),
p.getWorld().getName(),
p.getLocation().getX(),
p.getLocation().getY(),
p.getLocation().getZ(),
deathMsg,
cause,
killerUuid,
killerName,
killerType,
p.getLevel(),
itemsLost
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("is_anchor_spawn", event.isAnchorSpawn());
details.put("is_bed_spawn", event.isBedSpawn());
details.put("world", event.getRespawnLocation().getWorld().getName());
plugin.getDb().insertPlayerStat(
"respawn",
p.getUniqueId().toString(),
p.getName(),
null, null, details
);
}
}

View File

@@ -0,0 +1,151 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.*;
import org.bukkit.event.player.PlayerBedEnterEvent;
import java.util.HashMap;
import java.util.Map;
/**
* Miscellaneous player events: teleport, gamemode, bed, level,
* arrow-shooting (as projectile), hand-swap, sleeping, etc.
*
* @author SimolZimol
*/
public class PlayerMiscListener implements Listener {
private final PaperLoggerPlugin plugin;
public PlayerMiscListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
/** Teleport (all causes) */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
Player p = event.getPlayer();
if (event.getFrom().equals(event.getTo())) return; // no movement
String cause = event.getCause().name();
// Only log relevant causes (COMMAND, PLUGIN, NETHER_PORTAL, etc.)
plugin.getDb().insertTeleport(
p.getUniqueId().toString(),
p.getName(),
event.getFrom().getWorld().getName(),
event.getFrom().getX(), event.getFrom().getY(), event.getFrom().getZ(),
event.getTo().getWorld().getName(),
event.getTo().getX(), event.getTo().getY(), event.getTo().getZ(),
cause
);
}
/** Gamemode change */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onGameMode(PlayerGameModeChangeEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("cause", event.getCause().name());
plugin.getDb().insertPlayerStat(
"gamemode_change",
p.getUniqueId().toString(),
p.getName(),
p.getGameMode().name(),
event.getNewGameMode().name(),
details
);
}
/** Level change (XP) */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onLevelChange(PlayerLevelChangeEvent event) {
if (Math.abs(event.getNewLevel() - event.getOldLevel()) == 0) return;
Player p = event.getPlayer();
plugin.getDb().insertPlayerStat(
"level_change",
p.getUniqueId().toString(),
p.getName(),
String.valueOf(event.getOldLevel()),
String.valueOf(event.getNewLevel()),
null
);
}
/** Enter bed */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBedEnter(PlayerBedEnterEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("result", event.getBedEnterResult().name());
details.put("world", event.getBed().getWorld().getName());
details.put("x", event.getBed().getX());
details.put("y", event.getBed().getY());
details.put("z", event.getBed().getZ());
plugin.getDb().insertPlayerStat("bed_enter", p.getUniqueId().toString(), p.getName(), null, null, details);
}
/** Leave bed */
@EventHandler(priority = EventPriority.MONITOR)
public void onBedLeave(PlayerBedLeaveEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("world", event.getBed().getWorld().getName());
plugin.getDb().insertPlayerStat("bed_leave", p.getUniqueId().toString(), p.getName(), null, null, details);
}
/** Item held change (hand slot) */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onItemHeld(PlayerItemHeldEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("from_slot", event.getPreviousSlot());
details.put("to_slot", event.getNewSlot());
String newItem = p.getInventory().getItem(event.getNewSlot()) != null
? p.getInventory().getItem(event.getNewSlot()).getType().name() : "EMPTY";
details.put("new_item", newItem);
plugin.getDb().insertPlayerStat("item_held_change", p.getUniqueId().toString(), p.getName(),
String.valueOf(event.getPreviousSlot()), String.valueOf(event.getNewSlot()), details);
}
/** Swap hands (F key) */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSwapHands(PlayerSwapHandItemsEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
if (event.getMainHandItem() != null) details.put("main_hand", event.getMainHandItem().getType().name());
if (event.getOffHandItem() != null) details.put("off_hand", event.getOffHandItem().getType().name());
plugin.getDb().insertPlayerStat("swap_hands", p.getUniqueId().toString(), p.getName(), null, null, details);
}
/** Fishing activity */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onFish(PlayerFishEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("state", event.getState().name());
if (event.getCaught() != null) details.put("caught", event.getCaught().getType().name());
details.put("exp", event.getExpToDrop());
plugin.getDb().insertPlayerStat("fish", p.getUniqueId().toString(), p.getName(), null, null, details);
}
/** Player interaction with entity */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInteractEntity(PlayerInteractEntityEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("entity_type", event.getRightClicked().getType().name());
details.put("entity_uuid", event.getRightClicked().getUniqueId().toString());
details.put("hand", event.getHand().name());
plugin.getDb().insertPlayerStat("interact_entity", p.getUniqueId().toString(), p.getName(), null, null, details);
}
/** OP status changed */
@EventHandler(priority = EventPriority.MONITOR)
public void onOp(PlayerToggleSneakEvent event) {
// used for OP-change updated via upsertPlayer on the next join
}
}

View File

@@ -0,0 +1,100 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handles login, logout and basic player session data.
*
* @author SimolZimol
*/
public class PlayerSessionListener implements Listener {
private final PaperLoggerPlugin plugin;
/** Stores login timestamp per UUID for session duration calculation. */
private final Map<UUID, Long> loginTimes = new ConcurrentHashMap<>();
public PlayerSessionListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
/**
* Login event: player has authenticated and is connecting.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onLogin(PlayerLoginEvent event) {
Player p = event.getPlayer();
String ip = event.getAddress() != null ? event.getAddress().getHostAddress() : "unknown";
plugin.getDb().upsertPlayer(
p.getUniqueId().toString(),
p.getName(),
p.getDisplayName().length() > 64 ? p.getDisplayName().substring(0, 64) : p.getDisplayName(),
ip,
p.locale().toLanguageTag(),
p.isOp()
);
}
/**
* Join event: player appears in-game.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onJoin(PlayerJoinEvent event) {
Player p = event.getPlayer();
loginTimes.put(p.getUniqueId(), System.currentTimeMillis());
String ip = p.getAddress() != null
? p.getAddress().getAddress().getHostAddress() : "unknown";
// Session is opened by the Velocity proxy on PostLoginEvent
Map<String, Object> details = new HashMap<>();
details.put("join_message", event.joinMessage() != null ? event.joinMessage().toString() : "");
details.put("first_join", !p.hasPlayedBefore());
details.put("gamemode", p.getGameMode().name());
plugin.getDb().insertServerEvent("player_join",
p.getName() + " joined the server", details);
}
/**
* Quit event: player leaves the server.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
Player p = event.getPlayer();
UUID uuid = p.getUniqueId();
Long storedLogin = loginTimes.remove(uuid);
long durationSec = storedLogin != null ? (System.currentTimeMillis() - storedLogin) / 1000 : 0;
// Session is closed by the Velocity proxy on DisconnectEvent
Map<String, Object> details = new HashMap<>();
details.put("reason", event.getReason().name());
details.put("playtime_sec", durationSec);
plugin.getDb().insertServerEvent("player_quit",
p.getName() + " left the server", details);
}
/**
* Kick event: player was kicked.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onKick(PlayerKickEvent event) {
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("reason", event.getReason());
details.put("cause", event.getCause().name());
plugin.getDb().insertServerEvent("player_kick",
p.getName() + " was kicked: " + event.getReason(), details);
}
}

View File

@@ -0,0 +1,98 @@
package de.simolzimol.mclogger.paper.listeners;
import de.simolzimol.mclogger.paper.PaperLoggerPlugin;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.weather.ThunderChangeEvent;
import org.bukkit.event.weather.WeatherChangeEvent;
import org.bukkit.event.world.*;
import java.util.HashMap;
import java.util.Map;
/**
* Logs world events: weather, thunder, portal creation,
* tree/mushroom growth, chunk load, etc.
*
* @author SimolZimol
*/
public class WorldListener implements Listener {
private final PaperLoggerPlugin plugin;
public WorldListener(PaperLoggerPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onWeather(WeatherChangeEvent event) {
Map<String, Object> details = new HashMap<>();
details.put("to_storm", event.toWeatherState());
plugin.getDb().insertWorldEvent(
"weather_change",
event.getWorld().getName(),
null, null, null,
details
);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onThunder(ThunderChangeEvent event) {
Map<String, Object> details = new HashMap<>();
details.put("to_thunder", event.toThunderState());
plugin.getDb().insertWorldEvent(
"thunder_change",
event.getWorld().getName(),
null, null, null,
details
);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPortal(PortalCreateEvent event) {
Map<String, Object> details = new HashMap<>();
details.put("reason", event.getReason().name());
details.put("blocks", event.getBlocks().size());
if (event.getEntity() != null) {
details.put("entity", event.getEntity().getType().name());
}
plugin.getDb().insertWorldEvent(
"portal_create",
event.getWorld().getName(),
event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockX(),
event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockY(),
event.getBlocks().isEmpty() ? null : (double) event.getBlocks().get(0).getLocation().getBlockZ(),
details
);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onStructureGrow(StructureGrowEvent event) {
Map<String, Object> details = new HashMap<>();
details.put("species", event.getSpecies().name());
details.put("natural", event.isFromBonemeal());
details.put("player", event.getPlayer() != null ? event.getPlayer().getName() : null);
plugin.getDb().insertWorldEvent(
"structure_grow",
event.getWorld().getName(),
(double) event.getLocation().getBlockX(),
(double) event.getLocation().getBlockY(),
(double) event.getLocation().getBlockZ(),
details
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
Map<String, Object> details = new HashMap<>();
details.put("environment", event.getWorld().getEnvironment().name());
details.put("seed", event.getWorld().getSeed());
plugin.getDb().insertWorldEvent("world_load", event.getWorld().getName(), null, null, null, details);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldUnload(WorldUnloadEvent event) {
plugin.getDb().insertWorldEvent("world_unload", event.getWorld().getName(), null, null, null, null);
}
}

View File

@@ -0,0 +1,28 @@
# ============================================================
# MCLogger Paper Configuration
# Author: SimolZimol
# ============================================================
server:
# Unique name of this server (stored in the DB)
name: "survival-01"
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
# Number of concurrent database connections
pool-size: 10
logging:
# Log block events (can be disabled under high traffic)
blocks: true
# Log entity spawns (WARNING: can produce a huge number of entries!)
entity-spawns: false
# Log inventory clicks (except crafting, enchanting)
inventory-clicks: true
# Only log destructive events (break/explode) = lower volume
blocks-break-only: false

View File

@@ -0,0 +1,59 @@
name: MCLogger
version: '1.0.0'
main: de.simolzimol.mclogger.paper.PaperLoggerPlugin
api-version: '1.20'
description: Minecraft event logger
author: SimolZimol
softdepend:
- LuckPerms
commands:
mclogger:
description: MCLogger admin command
usage: /mclogger <help|reload|status|chat|commands|deaths|sessions|blocks|perms>
permission: mclogger.use
aliases:
- mcl
- mlog
permissions:
mclogger.use:
description: Access to MCLogger commands
default: op
children:
mclogger.admin: true
mclogger.view.chat: true
mclogger.view.commands: true
mclogger.view.deaths: true
mclogger.view.sessions: true
mclogger.view.blocks: true
mclogger.view.perms: true
mclogger.admin:
description: Full access to MCLogger (reload, status)
default: op
mclogger.view.chat:
description: View chat logs in-game
default: op
mclogger.view.commands:
description: View command logs in-game
default: op
mclogger.view.deaths:
description: View death logs in-game
default: op
mclogger.view.sessions:
description: View session logs in-game
default: op
mclogger.view.blocks:
description: View block logs in-game
default: op
mclogger.view.perms:
description: View LuckPerms permission changes in-game
default: op

View File

@@ -0,0 +1,28 @@
# ============================================================
# MCLogger Paper Configuration
# Author: SimolZimol
# ============================================================
server:
# Unique name of this server (stored in the DB)
name: "survival-01"
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
# Number of concurrent database connections
pool-size: 10
logging:
# Log block events (can be disabled under high traffic)
blocks: true
# Log entity spawns (WARNING: can produce a huge number of entries!)
entity-spawns: false
# Log inventory clicks (except crafting, enchanting)
inventory-clicks: true
# Only log destructive events (break/explode) = lower volume
blocks-break-only: false

View File

@@ -0,0 +1,59 @@
name: MCLogger
version: '1.0.0'
main: de.simolzimol.mclogger.paper.PaperLoggerPlugin
api-version: '1.20'
description: Minecraft event logger
author: SimolZimol
softdepend:
- LuckPerms
commands:
mclogger:
description: MCLogger admin command
usage: /mclogger <help|reload|status|chat|commands|deaths|sessions|blocks|perms>
permission: mclogger.use
aliases:
- mcl
- mlog
permissions:
mclogger.use:
description: Access to MCLogger commands
default: op
children:
mclogger.admin: true
mclogger.view.chat: true
mclogger.view.commands: true
mclogger.view.deaths: true
mclogger.view.sessions: true
mclogger.view.blocks: true
mclogger.view.perms: true
mclogger.admin:
description: Full access to MCLogger (reload, status)
default: op
mclogger.view.chat:
description: View chat logs in-game
default: op
mclogger.view.commands:
description: View command logs in-game
default: op
mclogger.view.deaths:
description: View death logs in-game
default: op
mclogger.view.sessions:
description: View session logs in-game
default: op
mclogger.view.blocks:
description: View block logs in-game
default: op
mclogger.view.perms:
description: View LuckPerms permission changes in-game
default: op

View File

@@ -0,0 +1,3 @@
artifactId=mclogger-paper
groupId=de.simolzimol
version=1.0.0

View File

@@ -0,0 +1,16 @@
de\simolzimol\mclogger\paper\commands\MCLoggerCommand.class
de\simolzimol\mclogger\paper\database\DatabaseManager$ThrowingRunnable.class
de\simolzimol\mclogger\paper\PaperLoggerPlugin.class
de\simolzimol\mclogger\paper\listeners\InventoryListener.class
de\simolzimol\mclogger\paper\database\DatabaseManager.class
de\simolzimol\mclogger\paper\listeners\LuckPermsListener$1.class
de\simolzimol\mclogger\paper\listeners\EntityListener.class
de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.class
de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.class
de\simolzimol\mclogger\paper\listeners\BlockListener.class
de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.class
de\simolzimol\mclogger\paper\listeners\LuckPermsListener.class
de\simolzimol\mclogger\paper\listeners\WorldListener.class
de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.class
de\simolzimol\mclogger\paper\commands\MCLoggerCommand$RsConsumer.class
de\simolzimol\mclogger\paper\listeners\InventoryListener$1.class

View File

@@ -0,0 +1,12 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java

Binary file not shown.

Binary file not shown.

117
velocity-plugin/pom.xml Normal file
View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.simolzimol</groupId>
<artifactId>mclogger-velocity</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MCLogger-Velocity</name>
<description>Comprehensive Minecraft proxy event logger for Velocity with MariaDB storage</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<!-- Velocity API -->
<dependency>
<groupId>com.velocitypowered</groupId>
<artifactId>velocity-api</artifactId>
<version>3.3.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- HikariCP Connection Pooling -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<!-- MariaDB JDBC Driver -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.4.1</version>
</dependency>
<!-- Gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>de.simolzimol.mclogger.velocity.lib.hikari</shadedPattern>
</relocation>
<relocation>
<pattern>org.mariadb</pattern>
<shadedPattern>de.simolzimol.mclogger.velocity.lib.mariadb</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/DEPENDENCIES</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
<!-- Annotation Processor für Velocity -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>com.velocitypowered</groupId>
<artifactId>velocity-api</artifactId>
<version>3.3.0-SNAPSHOT</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,121 @@
package de.simolzimol.mclogger.velocity;
import com.google.inject.Inject;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import de.simolzimol.mclogger.velocity.database.VelocityDatabaseManager;
import de.simolzimol.mclogger.velocity.listeners.VelocityEventListener;
import org.spongepowered.configurate.ConfigurationNode;
import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
import org.slf4j.Logger;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
/**
* MCLogger Velocity Proxy Plugin entry point
*
* @author SimolZimol
* @version 1.0.0
*/
@Plugin(
id = "mclogger-velocity",
name = "MCLogger-Velocity",
version = "1.0.0",
description = "Comprehensive proxy event logger with MariaDB storage",
authors = {"SimolZimol"},
url = "https://github.com/SimolZimol/MCLogger"
)
public class VelocityLoggerPlugin {
private final ProxyServer server;
private final Logger logger;
private final Path dataDirectory;
private VelocityDatabaseManager db;
@Inject
public VelocityLoggerPlugin(ProxyServer server, Logger logger,
@DataDirectory Path dataDirectory) {
this.server = server;
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onInitialize(ProxyInitializeEvent event) {
// Load configuration
ConfigurationNode cfg = loadConfig();
if (cfg == null) {
logger.error("[MCLogger] Failed to load configuration!");
return;
}
// Database connection
db = new VelocityDatabaseManager(this);
if (!db.connect(cfg)) {
logger.error("[MCLogger] No database connection plugin inactive.");
return;
}
// Register plugin messaging channel incoming from Paper backends
server.getChannelRegistrar().register(MinecraftChannelIdentifier.from("mclogger:logged"));
// Register listeners
server.getEventManager().register(this, new VelocityEventListener(this));
// Log proxy start
Map<String, Object> details = new HashMap<>();
details.put("velocity_version", server.getVersion().getVersion());
details.put("max_players", server.getConfiguration().getShowMaxPlayers());
db.insertProxyEvent("proxy_start", null, null, null, null, null, details);
logger.info("[MCLogger] Velocity plugin started! Proxy: " + db.getProxyName());
}
@Subscribe
public void onShutdown(ProxyShutdownEvent event) {
if (db != null && db.isConnected()) {
Map<String, Object> details = new HashMap<>();
details.put("online_players", server.getPlayerCount());
db.insertProxyEvent("proxy_stop", null, null, null, null, null, details);
try { Thread.sleep(300); } catch (InterruptedException ignored) {}
db.disconnect();
}
logger.info("[MCLogger] Velocity plugin shut down.");
}
private ConfigurationNode loadConfig() {
try {
if (!Files.exists(dataDirectory)) {
Files.createDirectories(dataDirectory);
}
Path configFile = dataDirectory.resolve("config.yml");
if (!Files.exists(configFile)) {
try (InputStream in = getClass().getResourceAsStream("/velocity-config.yml")) {
if (in != null) Files.copy(in, configFile);
}
}
YamlConfigurationLoader loader = YamlConfigurationLoader.builder()
.path(configFile)
.build();
return loader.load();
} catch (IOException e) {
logger.error("[MCLogger] Error loading configuration: " + e.getMessage());
return null;
}
}
public ProxyServer getServer() { return server; }
public Logger getLogger() { return logger; }
public VelocityDatabaseManager getDb() { return db; }
}

View File

@@ -0,0 +1,262 @@
package de.simolzimol.mclogger.velocity.database;
import com.google.gson.Gson;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import de.simolzimol.mclogger.velocity.VelocityLoggerPlugin;
import org.spongepowered.configurate.ConfigurationNode;
import java.sql.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
/**
* MariaDB manager for the Velocity plugin.
* Writes proxy events asynchronously to the database.
*
* @author SimolZimol
*/
public class VelocityDatabaseManager {
private final VelocityLoggerPlugin plugin;
private HikariDataSource dataSource;
private String proxyName;
private static final Gson GSON = new Gson();
public VelocityDatabaseManager(VelocityLoggerPlugin plugin) {
this.plugin = plugin;
}
// --------------------------------------------------------
// Connection
// --------------------------------------------------------
public boolean connect(ConfigurationNode cfg) {
proxyName = cfg.node("proxy", "name").getString("proxy-01");
HikariConfig hk = new HikariConfig();
hk.setDriverClassName("de.simolzimol.mclogger.velocity.lib.mariadb.jdbc.Driver");
hk.setJdbcUrl(String.format("jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
cfg.node("database", "host").getString("localhost"),
cfg.node("database", "port").getInt(3306),
cfg.node("database", "database").getString("mclogger"),
cfg.node("database", "ssl").getBoolean(false)));
hk.setUsername(cfg.node("database", "username").getString("root"));
hk.setPassword(cfg.node("database", "password").getString(""));
hk.setMaximumPoolSize(cfg.node("database", "pool-size").getInt(5));
hk.setMinimumIdle(1);
hk.setConnectionTimeout(30_000);
hk.setIdleTimeout(600_000);
hk.setPoolName("MCLogger-Velocity");
try {
dataSource = new HikariDataSource(hk);
registerProxy(cfg);
plugin.getLogger().info("MCLogger-Velocity: Database connected - Proxy: " + proxyName);
return true;
} catch (Exception e) {
plugin.getLogger().error("MCLogger-Velocity: Database connection failed!", e);
return false;
}
}
public void disconnect() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
private void registerProxy(ConfigurationNode cfg) throws SQLException {
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO servers (server_name, server_type) VALUES (?,?) " +
"ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP(3)")) {
ps.setString(1, proxyName);
ps.setString(2, "velocity");
ps.executeUpdate();
}
}
Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
// --------------------------------------------------------
// Async helper
// --------------------------------------------------------
private void asyncExec(ThrowingRunnable r) {
CompletableFuture.runAsync(() -> {
try {
r.run();
} catch (Exception e) {
plugin.getLogger().warn("MCLogger-Velocity: DB error: " + e.getMessage());
}
});
}
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
// --------------------------------------------------------
// Players
// --------------------------------------------------------
public void upsertPlayer(String uuid, String username, String ip) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO players (uuid, username, ip_address) VALUES (?,?,?) " +
"ON DUPLICATE KEY UPDATE username=VALUES(username), ip_address=VALUES(ip_address), last_seen=CURRENT_TIMESTAMP(3)")) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, ip);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Proxy Events (central table)
// --------------------------------------------------------
public void insertProxyEvent(String type, String playerUuid, String playerName,
String fromServer, String toServer,
String ip, Map<String, Object> details) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO proxy_events (event_type, player_uuid, player_name, proxy_name, from_server, to_server, ip_address, details) " +
"VALUES (?,?,?,?,?,?,?,?)")) {
ps.setString(1, type);
ps.setString(2, playerUuid);
ps.setString(3, playerName);
ps.setString(4, proxyName);
ps.setString(5, fromServer);
ps.setString(6, toServer);
ps.setString(7, ip);
ps.setString(8, details != null ? GSON.toJson(details) : null);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Chat & Commands (shared tables)
// --------------------------------------------------------
public void insertChat(String uuid, String name, String message) {
insertChatWithServer(uuid, name, proxyName, message);
}
public void insertChatWithServer(String uuid, String name, String serverName, String message) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_chat (player_uuid, player_name, server_name, message, channel) VALUES (?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, message);
ps.setString(5, "global");
ps.executeUpdate();
}
});
}
public void insertCommand(String uuid, String name, String command) {
insertCommandWithServer(uuid, name, proxyName, command);
}
public void insertCommandWithServer(String uuid, String name, String serverName, String command) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_commands (player_uuid, player_name, server_name, command) VALUES (?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, serverName);
ps.setString(4, command);
ps.executeUpdate();
}
});
}
// --------------------------------------------------------
// Sessions
// --------------------------------------------------------
/** Opens a new session row. server_name is updated on disconnect to the last known backend. */
public void insertSessionLogin(String uuid, String name, String ip, String clientBrand) {
asyncExec(() -> {
String country = lookupCountry(ip);
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"INSERT INTO player_sessions (player_uuid, player_name, server_name, ip_address, country, client_version) VALUES (?,?,?,?,?,?)")) {
ps.setString(1, uuid);
ps.setString(2, name);
ps.setString(3, proxyName);
ps.setString(4, ip);
ps.setString(5, country);
ps.setString(6, clientBrand);
ps.executeUpdate();
}
});
}
/** Closes the open session, sets the actual backend server name and duration. */
public void closeSession(String uuid, String lastServer, long durationSec) {
asyncExec(() -> {
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"UPDATE player_sessions SET logout_time=CURRENT_TIMESTAMP(3), duration_sec=?, server_name=? " +
"WHERE player_uuid=? AND logout_time IS NULL ORDER BY login_time DESC LIMIT 1")) {
ps.setLong(1, durationSec);
ps.setString(2, lastServer);
ps.setString(3, uuid);
ps.executeUpdate();
}
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(
"UPDATE players SET total_playtime_sec = total_playtime_sec + ? WHERE uuid = ?")) {
ps.setLong(1, durationSec);
ps.setString(2, uuid);
ps.executeUpdate();
}
});
}
private String lookupCountry(String ip) {
if (ip == null || ip.equals("unknown") || ip.equals("::1")
|| ip.startsWith("127.") || ip.startsWith("10.")
|| ip.startsWith("192.168.") || ip.startsWith("172.")) {
return "Local";
}
try {
java.net.URL url = new java.net.URL("http://ip-api.com/json/" + ip + "?fields=country");
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(3000);
conn.setRequestMethod("GET");
try (java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) sb.append(line);
com.google.gson.JsonObject obj = new com.google.gson.Gson().fromJson(sb.toString(), com.google.gson.JsonObject.class);
if (obj != null && obj.has("country")) return obj.get("country").getAsString();
}
} catch (Exception ignored) {}
return "Unknown";
}
// --------------------------------------------------------
// Getter
// --------------------------------------------------------
public String getProxyName() { return proxyName; }
public boolean isConnected() { return dataSource != null && !dataSource.isClosed(); }
}

View File

@@ -0,0 +1,232 @@
package de.simolzimol.mclogger.velocity.listeners;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.command.CommandExecuteEvent;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.connection.PostLoginEvent;
import com.velocitypowered.api.event.player.PlayerChatEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.event.player.ServerPreConnectEvent;
import com.velocitypowered.api.proxy.Player;
import de.simolzimol.mclogger.velocity.VelocityLoggerPlugin;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* All Velocity event listeners:
* - Login / Disconnect
* - Server switch
* - Chat
* - Commands
*
* @author SimolZimol
*/
public class VelocityEventListener {
private final VelocityLoggerPlugin plugin;
/** Login timestamps for session duration calculation */
private final Map<UUID, Long> loginTimes = new ConcurrentHashMap<>();
/** Commands already logged by a Paper backend Velocity skips these */
private final Set<String> paperLoggedCommands = ConcurrentHashMap.newKeySet();
public VelocityEventListener(VelocityLoggerPlugin plugin) {
this.plugin = plugin;
}
// --------------------------------------------------------
// Login
// --------------------------------------------------------
@Subscribe(order = PostOrder.LAST)
public void onLogin(PostLoginEvent event) {
Player p = event.getPlayer();
loginTimes.put(p.getUniqueId(), System.currentTimeMillis());
String ip = p.getRemoteAddress() != null
? p.getRemoteAddress().getAddress().getHostAddress() : "unknown";
// Upsert player in DB
plugin.getDb().upsertPlayer(p.getUniqueId().toString(), p.getUsername(), ip);
// Open session row (server_name is filled in on disconnect with last known backend)
String clientBrand = p.getClientBrand() != null ? p.getClientBrand() : "unknown";
plugin.getDb().insertSessionLogin(p.getUniqueId().toString(), p.getUsername(), ip, clientBrand);
Map<String, Object> details = new HashMap<>();
details.put("ip", ip);
details.put("client_brand", p.getClientBrand() != null ? p.getClientBrand() : "unknown");
details.put("protocol", p.getProtocolVersion().getProtocol());
details.put("ping", p.getPing());
details.put("online_mode", !p.isOnlineMode() ? "offline/bedrock" : "java");
plugin.getDb().insertProxyEvent(
"login",
p.getUniqueId().toString(),
p.getUsername(),
null, null, ip, details
);
plugin.getLogger().info("[MCLogger] LOGIN: " + p.getUsername() + " (" + ip + ")");
}
// --------------------------------------------------------
// Disconnect
// --------------------------------------------------------
@Subscribe(order = PostOrder.LAST)
public void onDisconnect(DisconnectEvent event) {
Player p = event.getPlayer();
UUID uuid = p.getUniqueId();
Long loginTimeMs = loginTimes.remove(uuid);
long durationSec = loginTimeMs != null ? (System.currentTimeMillis() - loginTimeMs) / 1000 : 0;
String lastServer = p.getCurrentServer()
.map(s -> s.getServerInfo().getName())
.orElse(plugin.getDb().getProxyName());
// Close the session opened in onLogin
plugin.getDb().closeSession(uuid.toString(), lastServer, durationSec);
Map<String, Object> details = new HashMap<>();
details.put("reason", event.getLoginStatus().name());
details.put("session_duration_sec", durationSec);
details.put("current_server", lastServer);
plugin.getDb().insertProxyEvent(
"disconnect",
uuid.toString(),
p.getUsername(),
p.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse(null),
null, null, details
);
plugin.getLogger().info("[MCLogger] DISCONNECT: " + p.getUsername() +
" (duration: " + durationSec + "s, reason: " + event.getLoginStatus().name() + ")");
}
// --------------------------------------------------------
// Server switch (after switching)
// --------------------------------------------------------
@Subscribe(order = PostOrder.LAST)
public void onServerConnected(ServerConnectedEvent event) {
Player p = event.getPlayer();
String from = event.getPreviousServer().map(s -> s.getServerInfo().getName()).orElse(null);
String to = event.getServer().getServerInfo().getName();
Map<String, Object> details = new HashMap<>();
details.put("ping", p.getPing());
plugin.getDb().insertProxyEvent(
"server_switch",
p.getUniqueId().toString(),
p.getUsername(),
from, to, null, details
);
plugin.getLogger().info("[MCLogger] SERVER-SWITCH: " + p.getUsername() +
" | " + (from != null ? from : "joining") + "" + to);
}
// --------------------------------------------------------
// Server connect attempt (before switching)
// --------------------------------------------------------
@Subscribe(order = PostOrder.LAST)
public void onServerPreConnect(ServerPreConnectEvent event) {
if (event.getResult().getServer().isEmpty()) return;
Player p = event.getPlayer();
Map<String, Object> details = new HashMap<>();
details.put("target_server", event.getResult().getServer().get().getServerInfo().getName());
plugin.getDb().insertProxyEvent(
"server_switch",
p.getUniqueId().toString(),
p.getUsername(),
p.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse(null),
event.getResult().getServer().get().getServerInfo().getName(),
null, details
);
}
// --------------------------------------------------------
// Commands only proxy-native commands (e.g. /server, /glist)
// Commands Paper logs with position and notifies proxy; Velocity is fallback.
// --------------------------------------------------------
/**
* Receives plugin messages from Paper backends.
* When Paper has logged a command, it sends the key here so Velocity skips it.
*/
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!event.getIdentifier().getId().equals("mclogger:logged")) return;
// Prevent Velocity from forwarding this internal message to the client
event.setResult(PluginMessageEvent.ForwardResult.handled());
String key = new String(event.getData(), StandardCharsets.UTF_8);
paperLoggedCommands.add(key);
}
/**
* Schedules command logging with a 300 ms delay.
* If a Paper backend signals it already logged the command, the task is cancelled.
* On servers without the Paper plugin, Velocity logs it as fallback.
*/
@Subscribe(order = PostOrder.LAST)
public void onCommand(CommandExecuteEvent event) {
if (!(event.getCommandSource() instanceof Player)) return;
Player p = (Player) event.getCommandSource();
String rawCommand = event.getCommand();
// Key format matches what Paper sends: uuid|/command args
String key = p.getUniqueId() + "|/" + rawCommand;
String serverName = p.getCurrentServer()
.map(s -> s.getServerInfo().getName())
.orElse(plugin.getDb().getProxyName());
plugin.getServer().getScheduler()
.buildTask(plugin, () -> {
// If Paper already sent us a confirmation, skip avoid duplicate
if (paperLoggedCommands.remove(key)) return;
plugin.getDb().insertCommandWithServer(
p.getUniqueId().toString(),
p.getUsername(),
serverName,
"/" + rawCommand
);
})
.delay(Duration.ofMillis(300))
.schedule();
}
// Note: Chat is logged at proxy level (see onChat below).
// --------------------------------------------------------
// Chat logged at proxy level with correct backend server name
// --------------------------------------------------------
@Subscribe(order = PostOrder.LAST)
public void onChat(PlayerChatEvent event) {
if (!event.getResult().isAllowed()) return;
Player p = event.getPlayer();
String serverName = p.getCurrentServer()
.map(s -> s.getServerInfo().getName())
.orElse(plugin.getDb().getProxyName());
plugin.getDb().insertChatWithServer(
p.getUniqueId().toString(),
p.getUsername(),
serverName,
event.getMessage()
);
}
}

View File

@@ -0,0 +1,17 @@
# ============================================================
# MCLogger Velocity Konfiguration
# Author: SimolZimol
# ============================================================
proxy:
# Eindeutiger Name dieses Proxies (wird in der DB gespeichert)
name: "proxy-01"
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
pool-size: 5

View File

@@ -0,0 +1,17 @@
# ============================================================
# MCLogger Velocity Konfiguration
# Author: SimolZimol
# ============================================================
proxy:
# Eindeutiger Name dieses Proxies (wird in der DB gespeichert)
name: "proxy-01"
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
pool-size: 5

View File

@@ -0,0 +1 @@
{"id":"mclogger-velocity","name":"MCLogger-Velocity","version":"1.0.0","description":"Comprehensive proxy event logger with MariaDB storage","url":"https://github.com/SimolZimol/MCLogger","authors":["SimolZimol"],"dependencies":[],"main":"de.simolzimol.mclogger.velocity.VelocityLoggerPlugin"}

View File

@@ -0,0 +1,3 @@
artifactId=mclogger-velocity
groupId=de.simolzimol
version=1.0.0

View File

@@ -0,0 +1,5 @@
de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.class
velocity-plugin.json
de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager$ThrowingRunnable.class
de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.class
de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.class

View File

@@ -0,0 +1,3 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java

Binary file not shown.

24
web/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# MCLogger Web Panel
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
ENV PYTHONUNBUFFERED=1 \
FLASK_APP=app.py
# Non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "app:app"]

76
web/app.py Normal file
View File

@@ -0,0 +1,76 @@
"""
MCLogger Flask Web-Panel
Multi-Tenant mit Gruppen, Rollen & verschlüsselten DB-Zugangsdaten.
Coolify-kompatibel: alle Einstellungen via ENV.
"""
from datetime import datetime
from flask import Flask, session
from config import Config
from panel_db import init_databases, get_user_groups
from blueprints.auth import auth
from blueprints.site_admin import site_admin
from blueprints.group_admin import group_admin
from blueprints.panel import panel
def create_app() -> Flask:
app = Flask(__name__)
app.secret_key = Config.SECRET_KEY
# Blueprints registrieren
app.register_blueprint(auth)
app.register_blueprint(site_admin)
app.register_blueprint(group_admin)
app.register_blueprint(panel)
# Panel-Datenbank-Tabellen anlegen
try:
init_databases()
except Exception as e:
app.logger.warning(f"DB-Initialisierung fehlgeschlagen (noch nicht konfiguriert?): {e}")
# ── Template-Filter ───────────────────────────────────────
@app.template_filter("fmt_duration")
def fmt_duration(seconds):
if seconds is None:
return ""
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h: return f"{h}h {m}m"
elif m: return f"{m}m {s}s"
return f"{s}s"
@app.template_filter("fmt_dt")
def fmt_dt(dt):
if dt is None:
return ""
if isinstance(dt, str):
return dt
return dt.strftime("%d.%m.%Y %H:%M:%S")
@app.context_processor
def inject_globals():
uid = session.get("user_id")
try:
groups = get_user_groups(uid) if uid else []
except Exception:
groups = []
return {
"now": datetime.now(),
"app_version": "2.0.0",
"author": "SimolZimol",
"user_groups": groups,
}
return app
app = create_app()
if __name__ == "__main__":
app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG)

View File

@@ -0,0 +1 @@
# blueprints/__init__.py

93
web/blueprints/auth.py Normal file
View File

@@ -0,0 +1,93 @@
"""
MCLogger Authentifizierung
Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins.
"""
import json
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from panel_db import check_login, get_user_groups
auth = Blueprint("auth", __name__)
@auth.route("/login", methods=["GET", "POST"])
def login():
if session.get("user_id"):
return redirect(url_for("panel.dashboard"))
error = None
if request.method == "POST":
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
if user and user["is_site_admin"]:
flash("Bitte nutze den Site-Admin-Login.", "warning")
return redirect(url_for("auth.admin_login"))
if user:
groups = get_user_groups(user["id"])
if not groups:
error = "Du bist keiner Gruppe zugewiesen. Wende dich an einen Admin."
else:
_set_user_session(user, groups)
return redirect(url_for("panel.dashboard"))
else:
error = "Falscher Benutzername oder Passwort."
return render_template("auth/login.html", error=error)
@auth.route("/admin/login", methods=["GET", "POST"])
def admin_login():
if session.get("is_site_admin"):
return redirect(url_for("site_admin.dashboard"))
error = None
if request.method == "POST":
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
if user and user["is_site_admin"]:
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_site_admin"] = True
session["group_id"] = None
session["permissions"] = {}
return redirect(url_for("site_admin.dashboard"))
elif user:
error = "Keine Site-Admin-Berechtigung."
else:
error = "Falscher Benutzername oder Passwort."
return render_template("auth/admin_login.html", error=error)
@auth.route("/logout")
def logout():
session.clear()
return redirect(url_for("auth.login"))
@auth.route("/switch-group/<int:group_id>")
def switch_group(group_id):
if not session.get("user_id") or session.get("is_site_admin"):
return redirect(url_for("auth.login"))
user_id = session["user_id"]
groups = get_user_groups(user_id)
target = next((g for g in groups if g["id"] == group_id), None)
if not target:
flash("Gruppe nicht gefunden oder kein Zugriff.", "danger")
return redirect(url_for("panel.dashboard"))
_apply_group(target)
return redirect(url_for("panel.dashboard"))
def _set_user_session(user, groups):
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_site_admin"] = False
_apply_group(groups[0]) # Erste Gruppe als Standard
def _apply_group(group):
raw = group.get("permissions")
if isinstance(raw, str):
perms = json.loads(raw)
elif isinstance(raw, dict):
perms = raw
else:
perms = {}
session["group_id"] = group["id"]
session["group_name"] = group["name"]
session["role"] = group.get("role", "member")
session["permissions"] = perms

View File

@@ -0,0 +1,164 @@
"""
MCLogger Gruppen-Admin-Bereich
Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten.
"""
import json
from functools import wraps
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
import panel_db as db
group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin")
ALL_PERMISSIONS = [
("view_dashboard", "Dashboard"),
("view_players", "Spieler"),
("view_sessions", "Sessions"),
("view_chat", "Chat"),
("view_commands", "Commands"),
("view_deaths", "Tode"),
("view_blocks", "Block-Events"),
("view_proxy", "Proxy-Events"),
("view_server_events", "Server-Events"),
("view_perms", "Berechtigungen"),
]
def group_admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("user_id"):
return redirect(url_for("auth.login"))
if session.get("is_site_admin"):
return redirect(url_for("site_admin.dashboard"))
if session.get("role") != "admin":
flash("Du hast keine Gruppen-Admin-Berechtigung.", "danger")
return redirect(url_for("panel.dashboard"))
return f(*args, **kwargs)
return decorated
@group_admin.route("/")
@group_admin_required
def dashboard():
group_id = session["group_id"]
group = db.get_group_by_id(group_id)
members = db.get_group_members(group_id)
has_db = db.has_db_configured(group_id)
return render_template("group_admin/dashboard.html",
group=group, members=members, has_db=has_db)
# ──────────────────────────────────────────────────────────────
# Mitglieder
# ──────────────────────────────────────────────────────────────
@group_admin.route("/members")
@group_admin_required
def members():
group_id = session["group_id"]
group = db.get_group_by_id(group_id)
members = db.get_group_members(group_id)
all_users = db.list_all_users()
member_ids = {m["id"] for m in members}
non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]]
return render_template("group_admin/members.html",
group=group, members=members, non_members=non_members,
all_permissions=ALL_PERMISSIONS)
@group_admin.route("/members/add", methods=["POST"])
@group_admin_required
def member_add():
group_id = session["group_id"]
user_id = request.form.get("user_id", type=int)
role = request.form.get("role", "member")
if user_id:
db.add_group_member(user_id, group_id, role)
flash("Mitglied hinzugefügt.", "success")
return redirect(url_for("group_admin.members"))
@group_admin.route("/members/<int:user_id>/edit", methods=["GET", "POST"])
@group_admin_required
def member_edit(user_id):
group_id = session["group_id"]
group = db.get_group_by_id(group_id)
member = db.get_group_member(user_id, group_id)
user = db.get_user_by_id(user_id)
if not member or not user:
flash("Mitglied nicht gefunden.", "danger")
return redirect(url_for("group_admin.members"))
raw_perms = member.get("permissions")
current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {})
if request.method == "POST":
role = request.form.get("role", "member")
new_perms = {key: (request.form.get(key) == "1") for key, _ in ALL_PERMISSIONS}
db.update_member(user_id, group_id, role, new_perms)
flash("Berechtigungen aktualisiert.", "success")
return redirect(url_for("group_admin.members"))
return render_template("group_admin/member_edit.html",
group=group, user=user, member=member,
current_perms=current_perms, all_permissions=ALL_PERMISSIONS)
@group_admin.route("/members/<int:user_id>/remove", methods=["POST"])
@group_admin_required
def member_remove(user_id):
if user_id == session["user_id"]:
flash("Du kannst dich nicht selbst entfernen.", "danger")
else:
db.remove_group_member(user_id, session["group_id"])
flash("Mitglied entfernt.", "success")
return redirect(url_for("group_admin.members"))
# ──────────────────────────────────────────────────────────────
# Datenbank-Konfiguration
# ──────────────────────────────────────────────────────────────
@group_admin.route("/database", methods=["GET", "POST"])
@group_admin_required
def database():
group_id = session["group_id"]
group = db.get_group_by_id(group_id)
has_db = db.has_db_configured(group_id)
error = None
if request.method == "POST":
host = request.form.get("host", "").strip()
port = request.form.get("port", "3306").strip()
user = request.form.get("user", "").strip()
password = request.form.get("password", "")
database_name = request.form.get("database", "").strip()
if not all([host, port, user, database_name]):
error = "Host, Port, Benutzer und Datenbankname sind Pflichtfelder."
else:
try:
# Verbindung testen
import pymysql
test = pymysql.connect(
host=host, port=int(port), user=user,
password=password, database=database_name,
connect_timeout=5
)
test.close()
db.set_group_db_creds(group_id, host, int(port), user, password, database_name)
flash("Datenbankverbindung gespeichert und getestet ✓", "success")
return redirect(url_for("group_admin.database"))
except Exception as e:
error = f"Verbindungstest fehlgeschlagen: {e}"
return render_template("group_admin/database.html",
group=group, has_db=has_db, error=error)
@group_admin.route("/database/delete", methods=["POST"])
@group_admin_required
def database_delete():
db.delete_group_db_creds(session["group_id"])
flash("Datenbankverbindung entfernt.", "success")
return redirect(url_for("group_admin.database"))

410
web/blueprints/panel.py Normal file
View File

@@ -0,0 +1,410 @@
"""
MCLogger Panel (MC-Daten)
Zeigt die Minecraft-Logdaten der Gruppe an.
Die Datenbankverbindung kommt aus den verschlüsselten Gruppen-Credentials.
"""
from functools import wraps
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort
import pymysql
import pymysql.cursors
import panel_db as pdb
panel = Blueprint("panel", __name__)
# ─────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ─────────────────────────────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("user_id"):
return redirect(url_for("auth.login"))
if session.get("is_site_admin") and not session.get("group_id"):
return redirect(url_for("site_admin.dashboard"))
if not session.get("group_id"):
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def perm_required(perm):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if session.get("is_site_admin") or session.get("role") == "admin":
return f(*args, **kwargs)
perms = session.get("permissions", {})
if not perms.get(perm, False):
flash("Du hast keine Berechtigung, diese Seite zu sehen.", "danger")
return redirect(url_for("panel.dashboard"))
return f(*args, **kwargs)
return wrapped
return decorator
def get_mc_db():
"""Liefert eine Datenbankverbindung zur MC-Datenbank der aktuellen Gruppe."""
group_id = session.get("group_id")
if not group_id:
abort(403)
creds = pdb.get_group_db_creds(group_id)
if not creds:
abort(503)
return pymysql.connect(
host=creds["host"],
port=creds["port"],
user=creds["user"],
password=creds["password"],
database=creds["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
connect_timeout=10,
)
def query(sql, args=None, fetchone=False):
conn = get_mc_db()
try:
with conn.cursor() as cur:
cur.execute(sql, args or ())
return cur.fetchone() if fetchone else cur.fetchall()
finally:
conn.close()
def query_paged(sql, count_sql, args=None, page=1, per_page=50):
args = args or ()
total_row = query(count_sql, args, fetchone=True)
total = list(total_row.values())[0] if total_row else 0
pages = max(1, (total + per_page - 1) // per_page)
offset = (page - 1) * per_page
rows = query(sql + f" LIMIT {per_page} OFFSET {offset}", args)
return rows, total, pages
# ─────────────────────────────────────────────────────────────
# Fehler-Handler wenn DB nicht konfiguriert
# ─────────────────────────────────────────────────────────────
@panel.errorhandler(503)
def no_db(e):
return render_template("panel/no_db.html"), 503
# ─────────────────────────────────────────────────────────────
# Dashboard
# ─────────────────────────────────────────────────────────────
@panel.route("/")
@login_required
@perm_required("view_dashboard")
def dashboard():
group_id = session["group_id"]
if not pdb.has_db_configured(group_id):
return render_template("panel/no_db.html")
try:
stats = {
"players_total": query("SELECT COUNT(*) AS c FROM players", fetchone=True)["c"],
"sessions_today": query("SELECT COUNT(*) AS c FROM player_sessions WHERE login_time >= CURDATE()", fetchone=True)["c"],
"chat_today": query("SELECT COUNT(*) AS c FROM player_chat WHERE timestamp >= CURDATE()", fetchone=True)["c"],
"commands_today": query("SELECT COUNT(*) AS c FROM player_commands WHERE timestamp >= CURDATE()", fetchone=True)["c"],
"blocks_today": query("SELECT COUNT(*) AS c FROM block_events WHERE timestamp >= CURDATE()", fetchone=True)["c"],
"deaths_today": query("SELECT COUNT(*) AS c FROM player_deaths WHERE timestamp >= CURDATE()", fetchone=True)["c"],
"proxy_events_today": query("SELECT COUNT(*) AS c FROM proxy_events WHERE timestamp >= CURDATE()", fetchone=True)["c"],
}
online = query("""
SELECT p.username, ps.server_name, ps.login_time, ps.country
FROM player_sessions ps
JOIN players p ON p.uuid = ps.player_uuid
WHERE ps.logout_time IS NULL
ORDER BY ps.login_time DESC
""")
top_players = query("""
SELECT username, total_playtime_sec
FROM players ORDER BY total_playtime_sec DESC LIMIT 10
""")
death_causes = query("""
SELECT cause, COUNT(*) AS cnt FROM player_deaths
WHERE timestamp >= NOW() - INTERVAL 7 DAY
GROUP BY cause ORDER BY cnt DESC LIMIT 8
""")
server_events = query("""
SELECT timestamp, event_type, server_name, message
FROM server_events
WHERE timestamp >= NOW() - INTERVAL 24 HOUR
ORDER BY timestamp DESC LIMIT 20
""")
except Exception as e:
flash(f"Datenbankfehler: {e}", "danger")
return render_template("panel/no_db.html")
return render_template("panel/dashboard.html",
stats=stats, online=online, top_players=top_players,
death_causes=death_causes, server_events=server_events)
# ─────────────────────────────────────────────────────────────
# Spieler
# ─────────────────────────────────────────────────────────────
@panel.route("/players")
@login_required
@perm_required("view_players")
def players():
search = request.args.get("q", "")
page = max(1, request.args.get("page", 1, type=int))
if search:
base = "FROM players WHERE username LIKE %s"
args = (f"%{search}%",)
else:
base = "FROM players WHERE 1"
args = ()
rows, total, pages = query_paged(
f"SELECT * {base} ORDER BY last_seen DESC",
f"SELECT COUNT(*) AS c {base}", args, page)
return render_template("panel/players.html",
players=rows, total=total, pages=pages, page=page, search=search)
@panel.route("/players/<uuid>")
@login_required
@perm_required("view_players")
def player_detail(uuid):
player = query("SELECT * FROM players WHERE uuid = %s", (uuid,), fetchone=True)
if not player:
flash("Spieler nicht gefunden.", "danger")
return redirect(url_for("panel.players"))
perms = session.get("permissions", {})
is_admin = session.get("is_site_admin") or session.get("role") == "admin"
return render_template("panel/player_detail.html",
player=player,
sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)),
chat = query("SELECT * FROM player_chat WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_chat")) else [],
commands = query("SELECT * FROM player_commands WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 50", (uuid,)) if (is_admin or perms.get("view_commands")) else [],
deaths = query("SELECT * FROM player_deaths WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)) if (is_admin or perms.get("view_deaths")) else [],
teleports = query("SELECT * FROM player_teleports WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 20", (uuid,)),
stats = query("SELECT * FROM player_stats WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)),
proxy_events = query("SELECT * FROM proxy_events WHERE player_uuid=%s ORDER BY timestamp DESC LIMIT 30", (uuid,)) if (is_admin or perms.get("view_proxy")) else [],
)
# ─────────────────────────────────────────────────────────────
# Sessions
# ─────────────────────────────────────────────────────────────
@panel.route("/sessions")
@login_required
@perm_required("view_sessions")
def sessions():
page = max(1, request.args.get("page", 1, type=int))
player = request.args.get("player", "")
server = request.args.get("server", "")
conditions, args = [], []
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
if server: conditions.append("server_name = %s"); args.append(server)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM player_sessions {where} ORDER BY login_time DESC",
f"SELECT COUNT(*) AS c FROM player_sessions {where}", tuple(args), page)
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_sessions ORDER BY server_name")]
return render_template("panel/sessions.html",
rows=rows, total=total, pages=pages, page=page,
player=player, server=server, servers=servers)
# ─────────────────────────────────────────────────────────────
# Chat
# ─────────────────────────────────────────────────────────────
@panel.route("/chat")
@login_required
@perm_required("view_chat")
def chat():
page = max(1, request.args.get("page", 1, type=int))
search = request.args.get("q", ""); server = request.args.get("server", "")
date_from = request.args.get("from", ""); date_to = request.args.get("to", "")
conditions, args = [], []
if search: conditions.append("message LIKE %s"); args.append(f"%{search}%")
if server: conditions.append("server_name = %s"); args.append(server)
if date_from: conditions.append("timestamp >= %s"); args.append(date_from)
if date_to: conditions.append("timestamp <= %s"); args.append(date_to + " 23:59:59")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM player_chat {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM player_chat {where}", tuple(args), page)
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_chat ORDER BY server_name")]
return render_template("panel/chat.html",
rows=rows, total=total, pages=pages, page=page,
search=search, server=server, servers=servers, date_from=date_from, date_to=date_to)
# ─────────────────────────────────────────────────────────────
# Commands
# ─────────────────────────────────────────────────────────────
@panel.route("/commands")
@login_required
@perm_required("view_commands")
def commands():
page = max(1, request.args.get("page", 1, type=int))
player = request.args.get("player", ""); search = request.args.get("q", ""); server = request.args.get("server", "")
conditions, args = [], []
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
if search: conditions.append("command LIKE %s"); args.append(f"%{search}%")
if server: conditions.append("server_name = %s"); args.append(server)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM player_commands {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM player_commands {where}", tuple(args), page)
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM player_commands ORDER BY server_name")]
return render_template("panel/commands.html",
rows=rows, total=total, pages=pages, page=page,
player=player, search=search, server=server, servers=servers)
# ─────────────────────────────────────────────────────────────
# Tode
# ─────────────────────────────────────────────────────────────
@panel.route("/deaths")
@login_required
@perm_required("view_deaths")
def deaths():
page = max(1, request.args.get("page", 1, type=int))
player = request.args.get("player", ""); cause = request.args.get("cause", "")
conditions, args = [], []
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
if cause: conditions.append("cause = %s"); args.append(cause)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM player_deaths {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM player_deaths {where}", tuple(args), page)
causes = [r["cause"] for r in query("SELECT DISTINCT cause FROM player_deaths ORDER BY cause")]
return render_template("panel/deaths.html",
rows=rows, total=total, pages=pages, page=page, player=player, cause=cause, causes=causes)
# ─────────────────────────────────────────────────────────────
# Block-Events
# ─────────────────────────────────────────────────────────────
@panel.route("/blocks")
@login_required
@perm_required("view_blocks")
def blocks():
page = max(1, request.args.get("page", 1, type=int))
event_type = request.args.get("type", ""); player = request.args.get("player", "")
world = request.args.get("world", ""); server = request.args.get("server", ""); block = request.args.get("block", "")
conditions, args = [], []
if event_type: conditions.append("event_type = %s"); args.append(event_type)
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
if world: conditions.append("world = %s"); args.append(world)
if server: conditions.append("server_name = %s"); args.append(server)
if block: conditions.append("block_type LIKE %s"); args.append(f"%{block}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM block_events {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM block_events {where}", tuple(args), page)
worlds = [r["world"] for r in query("SELECT DISTINCT world FROM block_events ORDER BY world")]
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM block_events ORDER BY server_name")]
return render_template("panel/blocks.html",
rows=rows, total=total, pages=pages, page=page,
event_type=event_type, player=player, world=world, server=server, block=block,
worlds=worlds, servers=servers)
# ─────────────────────────────────────────────────────────────
# Proxy-Events
# ─────────────────────────────────────────────────────────────
@panel.route("/proxy")
@login_required
@perm_required("view_proxy")
def proxy():
page = max(1, request.args.get("page", 1, type=int))
event_type = request.args.get("type", ""); player = request.args.get("player", "")
conditions, args = [], []
if event_type: conditions.append("event_type = %s"); args.append(event_type)
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM proxy_events {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM proxy_events {where}", tuple(args), page)
return render_template("panel/proxy.html",
rows=rows, total=total, pages=pages, page=page, event_type=event_type, player=player)
# ─────────────────────────────────────────────────────────────
# Server-Events
# ─────────────────────────────────────────────────────────────
@panel.route("/server-events")
@login_required
@perm_required("view_server_events")
def server_events():
page = max(1, request.args.get("page", 1, type=int))
server = request.args.get("server", ""); etype = request.args.get("type", "")
conditions, args = [], []
if server: conditions.append("server_name = %s"); args.append(server)
if etype: conditions.append("event_type = %s"); args.append(etype)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM server_events {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM server_events {where}", tuple(args), page)
servers = [r["server_name"] for r in query("SELECT DISTINCT server_name FROM server_events ORDER BY server_name")]
etypes = [r["event_type"] for r in query("SELECT DISTINCT event_type FROM server_events ORDER BY event_type")]
return render_template("panel/server_events.html",
rows=rows, total=total, pages=pages, page=page,
server=server, etype=etype, servers=servers, etypes=etypes)
# ─────────────────────────────────────────────────────────────
# Berechtigungen (plugin_events)
# ─────────────────────────────────────────────────────────────
@panel.route("/perms")
@login_required
@perm_required("view_perms")
def perms():
page = max(1, request.args.get("page", 1, type=int))
player = request.args.get("player", ""); plugin_filter = request.args.get("plugin", ""); etype = request.args.get("type", "")
conditions, args = [], []
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
if plugin_filter: conditions.append("plugin_name = %s"); args.append(plugin_filter)
if etype: conditions.append("event_type LIKE %s"); args.append(f"%{etype}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows, total, pages = query_paged(
f"SELECT * FROM plugin_events {where} ORDER BY timestamp DESC",
f"SELECT COUNT(*) AS c FROM plugin_events {where}", tuple(args), page)
plugins = [r["plugin_name"] for r in query("SELECT DISTINCT plugin_name FROM plugin_events ORDER BY plugin_name")]
return render_template("panel/perms.html",
rows=rows, total=total, pages=pages, page=page,
player=player, plugin_filter=plugin_filter, etype=etype, plugins=plugins)
# ─────────────────────────────────────────────────────────────
# API
# ─────────────────────────────────────────────────────────────
@panel.route("/api/online")
@login_required
def api_online():
rows = query("""
SELECT p.username, ps.server_name, ps.login_time, ps.country
FROM player_sessions ps
JOIN players p ON p.uuid = ps.player_uuid
WHERE ps.logout_time IS NULL ORDER BY ps.login_time DESC
""")
return jsonify([dict(r) for r in rows])
@panel.route("/api/stats")
@login_required
def api_stats():
return jsonify({
"players_online": query("SELECT COUNT(*) AS c FROM player_sessions WHERE logout_time IS NULL", fetchone=True)["c"],
})

View File

@@ -0,0 +1,221 @@
"""
MCLogger Site-Admin-Bereich
Verwaltet alle Gruppen und Nutzer global.
"""
from functools import wraps
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
import panel_db as db
site_admin = Blueprint("site_admin", __name__, url_prefix="/admin")
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("is_site_admin"):
return redirect(url_for("auth.admin_login"))
return f(*args, **kwargs)
return decorated
# ──────────────────────────────────────────────────────────────
# Dashboard
# ──────────────────────────────────────────────────────────────
@site_admin.route("/")
@admin_required
def dashboard():
groups = db.list_all_groups()
users = db.list_all_users()
# Für jede Gruppe DB-Status prüfen
for g in groups:
g["has_db"] = db.has_db_configured(g["id"])
return render_template("admin/dashboard.html", groups=groups, users=users)
# ──────────────────────────────────────────────────────────────
# Gruppen verwalten
# ──────────────────────────────────────────────────────────────
@site_admin.route("/groups")
@admin_required
def groups():
all_groups = db.list_all_groups()
for g in all_groups:
g["has_db"] = db.has_db_configured(g["id"])
return render_template("admin/groups.html", groups=all_groups)
@site_admin.route("/groups/new", methods=["GET", "POST"])
@admin_required
def group_new():
if request.method == "POST":
name = request.form.get("name", "").strip()
desc = request.form.get("description", "").strip()
if not name:
flash("Gruppenname darf nicht leer sein.", "danger")
elif db.get_group_by_name(name):
flash("Eine Gruppe mit diesem Namen existiert bereits.", "danger")
else:
db.create_group(name, desc)
flash(f"Gruppe '{name}' erstellt.", "success")
return redirect(url_for("site_admin.groups"))
return render_template("admin/group_edit.html", group=None)
@site_admin.route("/groups/<int:group_id>/edit", methods=["GET", "POST"])
@admin_required
def group_edit(group_id):
group = db.get_group_by_id(group_id)
if not group:
flash("Gruppe nicht gefunden.", "danger")
return redirect(url_for("site_admin.groups"))
if request.method == "POST":
name = request.form.get("name", "").strip()
desc = request.form.get("description", "").strip()
if not name:
flash("Gruppenname darf nicht leer sein.", "danger")
else:
db.update_group(group_id, name, desc)
flash("Gruppe aktualisiert.", "success")
return redirect(url_for("site_admin.groups"))
return render_template("admin/group_edit.html", group=group)
@site_admin.route("/groups/<int:group_id>/delete", methods=["POST"])
@admin_required
def group_delete(group_id):
db.delete_group(group_id)
flash("Gruppe gelöscht.", "success")
return redirect(url_for("site_admin.groups"))
@site_admin.route("/groups/<int:group_id>/members")
@admin_required
def group_members(group_id):
group = db.get_group_by_id(group_id)
members = db.get_group_members(group_id)
all_users = db.list_all_users()
member_ids = {m["id"] for m in members}
non_members = [u for u in all_users if u["id"] not in member_ids]
return render_template("admin/group_members.html",
group=group, members=members, non_members=non_members)
@site_admin.route("/groups/<int:group_id>/members/add", methods=["POST"])
@admin_required
def group_member_add(group_id):
user_id = request.form.get("user_id", type=int)
role = request.form.get("role", "member")
if user_id:
db.add_group_member(user_id, group_id, role)
flash("Mitglied hinzugefügt.", "success")
return redirect(url_for("site_admin.group_members", group_id=group_id))
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/remove", methods=["POST"])
@admin_required
def group_member_remove(group_id, user_id):
db.remove_group_member(user_id, group_id)
flash("Mitglied entfernt.", "success")
return redirect(url_for("site_admin.group_members", group_id=group_id))
# ──────────────────────────────────────────────────────────────
# Nutzer verwalten
# ──────────────────────────────────────────────────────────────
@site_admin.route("/users")
@admin_required
def users():
return render_template("admin/users.html", users=db.list_all_users())
@site_admin.route("/users/new", methods=["GET", "POST"])
@admin_required
def user_new():
if request.method == "POST":
username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
is_site_admin = request.form.get("is_site_admin") == "1"
if not username or not email or not password:
flash("Alle Felder sind Pflichtfelder.", "danger")
elif db.get_user_by_username(username):
flash("Benutzername bereits vergeben.", "danger")
else:
db.create_user(username, email, password, is_site_admin)
flash(f"Nutzer '{username}' erstellt.", "success")
return redirect(url_for("site_admin.users"))
return render_template("admin/user_edit.html", user=None)
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
@admin_required
def user_edit(user_id):
user = db.get_user_by_id(user_id)
if not user:
flash("Nutzer nicht gefunden.", "danger")
return redirect(url_for("site_admin.users"))
if request.method == "POST":
username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip()
is_site_admin = request.form.get("is_site_admin") == "1"
new_password = request.form.get("new_password", "")
db.update_user(user_id, username, email, is_site_admin)
if new_password:
db.change_password(user_id, new_password)
flash("Passwort geändert.", "info")
flash("Nutzer aktualisiert.", "success")
return redirect(url_for("site_admin.users"))
return render_template("admin/user_edit.html", user=user)
@site_admin.route("/users/<int:user_id>/delete", methods=["POST"])
@admin_required
def user_delete(user_id):
if user_id == session.get("user_id"):
flash("Du kannst dich nicht selbst löschen.", "danger")
else:
db.delete_user(user_id)
flash("Nutzer gelöscht.", "success")
return redirect(url_for("site_admin.users"))
# ──────────────────────────────────────────────────────────────
# Als Gruppe anzeigen (Site-Admin liest Gruppen-DB)
# ──────────────────────────────────────────────────────────────
@site_admin.route("/view-group/<int:group_id>")
@admin_required
def view_group(group_id):
"""Site-Admin wechselt temporär in eine 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"))

46
web/config.py Normal file
View File

@@ -0,0 +1,46 @@
"""
MCLogger Konfiguration
Alle Einstellungen über ENV-Variablen (Coolify-kompatibel).
"""
import os
class Config:
# ── Flask ──────────────────────────────────────────────────
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-use-a-long-random-string-min-32-chars")
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "5000"))
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
# ── Panel-Datenbank (Nutzer, Gruppen, Mitgliedschaften) ────
PANEL_DB_HOST = os.getenv("PANEL_DB_HOST", "localhost")
PANEL_DB_PORT = int(os.getenv("PANEL_DB_PORT", "3306"))
PANEL_DB_USER = os.getenv("PANEL_DB_USER", "root")
PANEL_DB_PASSWORD = os.getenv("PANEL_DB_PASSWORD", "")
PANEL_DB_NAME = os.getenv("PANEL_DB_NAME", "mclogger_panel")
# ── Credentials-Datenbank (verschlüsselte MC-DB-Zugangsdaten) ──
CREDS_DB_HOST = os.getenv("CREDS_DB_HOST", os.getenv("PANEL_DB_HOST", "localhost"))
CREDS_DB_PORT = int(os.getenv("CREDS_DB_PORT", os.getenv("PANEL_DB_PORT", "3306")))
CREDS_DB_USER = os.getenv("CREDS_DB_USER", os.getenv("PANEL_DB_USER", "root"))
CREDS_DB_PASSWORD = os.getenv("CREDS_DB_PASSWORD", os.getenv("PANEL_DB_PASSWORD", ""))
CREDS_DB_NAME = os.getenv("CREDS_DB_NAME", "mclogger_creds")
# ── Sicherheit ────────────────────────────────────────────
PASSWORD_PEPPER = os.getenv("PASSWORD_PEPPER", "change-me-global-pepper-secret-never-change")
# Generieren: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
FERNET_KEY = os.getenv("FERNET_KEY", "")
# ── Standard-Berechtigungen neuer Gruppenmitglieder ───────
DEFAULT_PERMISSIONS = {
"view_dashboard": True,
"view_players": True,
"view_sessions": True,
"view_chat": True,
"view_commands": True,
"view_deaths": True,
"view_blocks": True,
"view_proxy": False,
"view_server_events": False,
"view_perms": False,
}

63
web/crypto.py Normal file
View File

@@ -0,0 +1,63 @@
"""
MCLogger Kryptographie-Utilities
- Passwort-Hashing: PBKDF2-HMAC-SHA256 mit Salt (pro Nutzer) + Pepper (global, via ENV)
- DB-Credential-Verschlüsselung: Fernet (symmetrisch, Schlüssel via ENV)
"""
import hashlib
import os
from cryptography.fernet import Fernet
from config import Config
# ─────────────────────────────────────────────────────────────
# Passwort-Hashing
# ─────────────────────────────────────────────────────────────
def generate_salt() -> str:
"""Generiert einen zufälligen 32-Byte Hex-Salt."""
return os.urandom(32).hex()
def hash_password(password: str, salt: str) -> str:
"""
Hasht ein Passwort mit PBKDF2-HMAC-SHA256.
Verwendet: salt (pro Nutzer) + pepper (global aus ENV)
"""
dk = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
(salt + Config.PASSWORD_PEPPER).encode("utf-8"),
iterations=260_000,
)
return dk.hex()
def verify_password(password: str, salt: str, stored_hash: str) -> bool:
"""Prüft ob ein Passwort korrekt ist."""
return hash_password(password, salt) == stored_hash
# ─────────────────────────────────────────────────────────────
# Fernet-Verschlüsselung (für DB-Zugangsdaten)
# ─────────────────────────────────────────────────────────────
def _get_fernet() -> Fernet:
key = Config.FERNET_KEY
if not key:
raise RuntimeError(
"FERNET_KEY ist nicht gesetzt! "
"Generieren: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)
if isinstance(key, str):
key = key.encode()
return Fernet(key)
def encrypt_str(plaintext: str) -> str:
"""Verschlüsselt einen String mit Fernet."""
return _get_fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
def decrypt_str(ciphertext: str) -> str:
"""Entschlüsselt einen Fernet-verschlüsselten String."""
return _get_fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8")

46
web/docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
version: "3.9"
services:
mclogger-panel:
build: .
image: mclogger-panel:latest
container_name: mclogger-panel
restart: unless-stopped
ports:
- "${PORT:-5000}:5000"
environment:
# ── Flask ──────────────────────────────────────────────
SECRET_KEY: "${SECRET_KEY}"
# ── Panel-Datenbank (Benutzer / Gruppen) ───────────────
PANEL_DB_HOST: "${PANEL_DB_HOST:-localhost}"
PANEL_DB_PORT: "${PANEL_DB_PORT:-3306}"
PANEL_DB_USER: "${PANEL_DB_USER:-mclogger_panel}"
PANEL_DB_PASSWORD: "${PANEL_DB_PASSWORD}"
PANEL_DB_NAME: "${PANEL_DB_NAME:-mclogger_panel}"
# ── Credentials-Datenbank (verschlüsselte MC-DB-Daten) ─
CREDS_DB_HOST: "${CREDS_DB_HOST:-localhost}"
CREDS_DB_PORT: "${CREDS_DB_PORT:-3306}"
CREDS_DB_USER: "${CREDS_DB_USER:-mclogger_creds}"
CREDS_DB_PASSWORD: "${CREDS_DB_PASSWORD}"
CREDS_DB_NAME: "${CREDS_DB_NAME:-mclogger_creds}"
# ── Sicherheit ──────────────────────────────────────────
# Fernet-Schlüssel (32 URL-safe base64 bytes):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
FERNET_KEY: "${FERNET_KEY}"
# Pepper für Passwort-Hashing (beliebige lange Zeichenkette)
PASSWORD_PEPPER: "${PASSWORD_PEPPER}"
# ── Server ──────────────────────────────────────────────
HOST: "0.0.0.0"
PORT: "5000"
DEBUG: "${DEBUG:-false}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s

349
web/panel_db.py Normal file
View File

@@ -0,0 +1,349 @@
"""
MCLogger Panel-Datenbank-Operationen
Verwaltet Nutzer, Gruppen, Mitgliedschaften (PANEL_DB)
und verschlüsselte MC-DB-Zugangsdaten (CREDS_DB).
"""
import json
import pymysql
import pymysql.cursors
from config import Config
from crypto import generate_salt, hash_password, verify_password, encrypt_str, decrypt_str
# ─────────────────────────────────────────────────────────────
# Datenbankverbindungen
# ─────────────────────────────────────────────────────────────
def get_panel_db():
return pymysql.connect(
host=Config.PANEL_DB_HOST,
port=Config.PANEL_DB_PORT,
user=Config.PANEL_DB_USER,
password=Config.PANEL_DB_PASSWORD,
database=Config.PANEL_DB_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
def get_creds_db():
return pymysql.connect(
host=Config.CREDS_DB_HOST,
port=Config.CREDS_DB_PORT,
user=Config.CREDS_DB_USER,
password=Config.CREDS_DB_PASSWORD,
database=Config.CREDS_DB_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
def _panel_query(sql, args=None, fetchone=False, write=False):
conn = get_panel_db()
try:
with conn.cursor() as cur:
cur.execute(sql, args or ())
if write:
return cur.lastrowid
return cur.fetchone() if fetchone else cur.fetchall()
finally:
conn.close()
def _creds_query(sql, args=None, fetchone=False, write=False):
conn = get_creds_db()
try:
with conn.cursor() as cur:
cur.execute(sql, args or ())
if write:
return cur.lastrowid
return cur.fetchone() if fetchone else cur.fetchall()
finally:
conn.close()
# ─────────────────────────────────────────────────────────────
# Initialisierung Tabellen anlegen
# ─────────────────────────────────────────────────────────────
PANEL_SCHEMA = [
"""CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
salt VARCHAR(64) NOT NULL,
is_site_admin TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS user_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
group_id INT NOT NULL,
role ENUM('admin','member') DEFAULT 'member',
permissions JSON,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_group (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
CREDS_SCHEMA = [
"""CREATE TABLE IF NOT EXISTS group_databases (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT UNIQUE NOT NULL,
enc_host TEXT NOT NULL,
enc_port TEXT NOT NULL,
enc_user TEXT NOT NULL,
enc_password TEXT NOT NULL,
enc_database TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
def init_databases():
"""Erstellt alle benötigten Tabellen falls nicht vorhanden."""
panel = get_panel_db()
try:
with panel.cursor() as cur:
for stmt in PANEL_SCHEMA:
cur.execute(stmt)
finally:
panel.close()
creds = get_creds_db()
try:
with creds.cursor() as cur:
for stmt in CREDS_SCHEMA:
cur.execute(stmt)
finally:
creds.close()
# ─────────────────────────────────────────────────────────────
# Nutzer
# ─────────────────────────────────────────────────────────────
def create_user(username: str, email: str, password: str, is_site_admin: bool = False) -> int:
salt = generate_salt()
pw_hash = hash_password(password, salt)
return _panel_query(
"INSERT INTO users (username, email, password_hash, salt, is_site_admin) VALUES (%s,%s,%s,%s,%s)",
(username, email, pw_hash, salt, int(is_site_admin)), write=True
)
def get_user_by_username(username: str):
return _panel_query("SELECT * FROM users WHERE username=%s", (username,), fetchone=True)
def get_user_by_id(user_id: int):
return _panel_query("SELECT * FROM users WHERE id=%s", (user_id,), fetchone=True)
def update_user(user_id: int, username: str, email: str, is_site_admin: bool):
_panel_query(
"UPDATE users SET username=%s, email=%s, is_site_admin=%s WHERE id=%s",
(username, email, int(is_site_admin), user_id), write=True
)
def change_password(user_id: int, new_password: str):
salt = generate_salt()
pw_hash = hash_password(new_password, salt)
_panel_query(
"UPDATE users SET password_hash=%s, salt=%s WHERE id=%s",
(pw_hash, salt, user_id), write=True
)
def delete_user(user_id: int):
_panel_query("DELETE FROM users WHERE id=%s", (user_id,), write=True)
def check_login(username: str, password: str):
"""Prüft Anmeldedaten. Gibt den Nutzer zurück oder None."""
user = get_user_by_username(username)
if not user:
return None
if not verify_password(password, user["salt"], user["password_hash"]):
return None
_panel_query("UPDATE users SET last_login=NOW() WHERE id=%s", (user["id"],), write=True)
return user
def list_all_users():
return _panel_query(
"SELECT u.*, COUNT(gm.group_id) AS group_count "
"FROM users u LEFT JOIN group_members gm ON gm.user_id=u.id "
"GROUP BY u.id ORDER BY u.username"
)
# ─────────────────────────────────────────────────────────────
# Gruppen
# ─────────────────────────────────────────────────────────────
def create_group(name: str, description: str = "") -> int:
return _panel_query(
"INSERT INTO user_groups (name, description) VALUES (%s,%s)",
(name, description), write=True
)
def get_group_by_id(group_id: int):
return _panel_query("SELECT * FROM user_groups WHERE id=%s", (group_id,), fetchone=True)
def get_group_by_name(name: str):
return _panel_query("SELECT * FROM user_groups WHERE name=%s", (name,), fetchone=True)
def update_group(group_id: int, name: str, description: str):
_panel_query(
"UPDATE user_groups SET name=%s, description=%s WHERE id=%s",
(name, description, group_id), write=True
)
def delete_group(group_id: int):
_panel_query("DELETE FROM user_groups WHERE id=%s", (group_id,), write=True)
def list_all_groups():
return _panel_query(
"SELECT g.*, COUNT(gm.user_id) AS member_count "
"FROM user_groups g LEFT JOIN group_members gm ON gm.group_id=g.id "
"GROUP BY g.id ORDER BY g.name"
)
# ─────────────────────────────────────────────────────────────
# Gruppenmitgliedschaften
# ─────────────────────────────────────────────────────────────
def get_user_groups(user_id: int):
return _panel_query(
"SELECT g.*, gm.role, gm.permissions "
"FROM user_groups g "
"JOIN group_members gm ON gm.group_id=g.id "
"WHERE gm.user_id=%s ORDER BY g.name",
(user_id,)
)
def get_group_member(user_id: int, group_id: int):
return _panel_query(
"SELECT * FROM group_members WHERE user_id=%s AND group_id=%s",
(user_id, group_id), fetchone=True
)
def get_group_members(group_id: int):
return _panel_query(
"SELECT u.id, u.username, u.email, u.last_login, gm.role, gm.permissions, gm.joined_at "
"FROM group_members gm "
"JOIN users u ON u.id=gm.user_id "
"WHERE gm.group_id=%s ORDER BY gm.role DESC, u.username",
(group_id,)
)
def add_group_member(user_id: int, group_id: int, role: str = "member", permissions: dict = None):
if permissions is None:
permissions = Config.DEFAULT_PERMISSIONS
_panel_query(
"INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s) "
"ON DUPLICATE KEY UPDATE role=VALUES(role), permissions=VALUES(permissions)",
(user_id, group_id, role, json.dumps(permissions)), write=True
)
def update_member(user_id: int, group_id: int, role: str, permissions: dict):
_panel_query(
"UPDATE group_members SET role=%s, permissions=%s WHERE user_id=%s AND group_id=%s",
(role, json.dumps(permissions), user_id, group_id), write=True
)
def remove_group_member(user_id: int, group_id: int):
_panel_query(
"DELETE FROM group_members WHERE user_id=%s AND group_id=%s",
(user_id, group_id), write=True
)
def get_permissions(user_id: int, group_id: int) -> dict:
"""Gibt die Berechtigungen des Nutzers in der Gruppe zurück."""
member = get_group_member(user_id, group_id)
if not member:
return {}
raw = member.get("permissions")
if isinstance(raw, str):
return json.loads(raw)
if isinstance(raw, dict):
return raw
return {}
# ─────────────────────────────────────────────────────────────
# MC-Datenbank-Zugangsdaten (verschlüsselt)
# ─────────────────────────────────────────────────────────────
def set_group_db_creds(group_id: int, host: str, port: int, user: str, password: str, database: str):
"""Verschlüsselt und speichert die MC-DB-Zugangsdaten einer Gruppe."""
_creds_query(
"INSERT INTO group_databases (group_id, enc_host, enc_port, enc_user, enc_password, enc_database) "
"VALUES (%s,%s,%s,%s,%s,%s) "
"ON DUPLICATE KEY UPDATE enc_host=VALUES(enc_host), enc_port=VALUES(enc_port), "
"enc_user=VALUES(enc_user), enc_password=VALUES(enc_password), enc_database=VALUES(enc_database)",
(group_id,
encrypt_str(host),
encrypt_str(str(port)),
encrypt_str(user),
encrypt_str(password),
encrypt_str(database)),
write=True
)
def get_group_db_creds(group_id: int) -> dict | None:
"""Gibt die entschlüsselten MC-DB-Zugangsdaten zurück oder None."""
row = _creds_query(
"SELECT * FROM group_databases WHERE group_id=%s",
(group_id,), fetchone=True
)
if not row:
return None
return {
"host": decrypt_str(row["enc_host"]),
"port": int(decrypt_str(row["enc_port"])),
"user": decrypt_str(row["enc_user"]),
"password": decrypt_str(row["enc_password"]),
"database": decrypt_str(row["enc_database"]),
}
def delete_group_db_creds(group_id: int):
_creds_query("DELETE FROM group_databases WHERE group_id=%s", (group_id,), write=True)
def has_db_configured(group_id: int) -> bool:
row = _creds_query(
"SELECT id FROM group_databases WHERE group_id=%s",
(group_id,), fetchone=True
)
return row is not None

4
web/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask==3.1.0
PyMySQL==1.1.1
cryptography==42.0.8
gunicorn==22.0.0

230
web/static/css/style.css Normal file
View File

@@ -0,0 +1,230 @@
/* ============================================================
MCLogger Admin Interface CSS
Author: SimolZimol
============================================================ */
:root {
--sidebar-width: 230px;
--sidebar-bg: #0f1117;
--sidebar-border: #1e2230;
--topbar-bg: #13161f;
--content-bg: #181c27;
--card-bg: #1e2230;
--card-border: #2a2f42;
--text-muted-custom: #6b7280;
--accent-green: #1db954;
}
/* ── Layout ─────────────────────────────────────────────── */
html, body {
height: 100%;
overflow: hidden;
background: var(--content-bg);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
}
#wrapper {
height: 100vh;
overflow: hidden;
}
/* ── Sidebar ─────────────────────────────────────────────── */
#sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
transition: width .25s ease, min-width .25s ease;
}
#sidebar.collapsed {
width: 64px;
min-width: 64px;
}
#sidebar.collapsed .sidebar-brand div,
#sidebar.collapsed .sidebar-brand small,
#sidebar.collapsed .nav-link span {
display: none;
}
.sidebar-brand {
padding: .25rem 0;
}
#sidebar .nav-link {
color: #9ca3af;
border-radius: 6px;
padding: .45rem .75rem;
font-size: .875rem;
display: flex;
align-items: center;
gap: .6rem;
transition: background .15s, color .15s;
white-space: nowrap;
}
#sidebar .nav-link i {
font-size: 1rem;
flex-shrink: 0;
}
#sidebar .nav-link:hover { background: rgba(255,255,255,.05); color: #e5e7eb; }
#sidebar .nav-link.active { background: rgba(29,185,84,.15); color: var(--accent-green); }
/* ── Topbar ──────────────────────────────────────────────── */
.topbar {
background: var(--topbar-bg);
border-bottom: 1px solid var(--sidebar-border);
height: 52px;
flex-shrink: 0;
}
/* ── Content ─────────────────────────────────────────────── */
#page-content {
display: flex;
flex-direction: column;
background: var(--content-bg);
height: 100vh;
}
main {
flex: 1;
overflow-y: auto;
}
/* ── Cards ───────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
}
.card-header {
background: rgba(0,0,0,.2);
border-bottom: 1px solid var(--card-border);
font-size: .85rem;
font-weight: 600;
color: #d1d5db;
}
/* ── Statistik-Karten ────────────────────────────────────── */
.stat-card { transition: transform .15s; cursor: default; }
.stat-card:hover { transform: translateY(-2px); }
.stat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1.3rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
color: #f3f4f6;
}
.stat-label {
font-size: .72rem;
color: var(--text-muted-custom);
margin-top: 2px;
}
/* ── Tabellen ────────────────────────────────────────────── */
.table {
color: #d1d5db;
font-size: .8rem;
}
.table > thead {
font-size: .75rem;
letter-spacing: .03em;
color: #9ca3af;
}
.table-hover > tbody > tr:hover > td {
background: rgba(255,255,255,.04);
}
.table-dark {
--bs-table-bg: rgba(0,0,0,.3);
}
/* ── Badges ──────────────────────────────────────────────── */
.badge { font-size: .7rem; font-weight: 500; }
/* ── Inputs ──────────────────────────────────────────────── */
.form-control, .form-select {
background-color: #111827;
border-color: var(--card-border);
color: #e5e7eb;
font-size: .8rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--accent-green);
box-shadow: 0 0 0 2px rgba(29,185,84,.25);
background-color: #111827;
color: #f9fafb;
}
.form-control::placeholder { color: #6b7280; }
/* ── Buttons ─────────────────────────────────────────────── */
.btn-success { background-color: var(--accent-green); border-color: var(--accent-green); }
.btn-success:hover { background-color: #17a34a; border-color: #17a34a; }
/* ── Pagination ──────────────────────────────────────────── */
.page-link {
background-color: var(--card-bg);
border-color: var(--card-border);
color: #9ca3af;
font-size: .8rem;
}
.page-link:hover { background-color: rgba(255,255,255,.07); color: #f3f4f6; }
.page-item.active .page-link { background-color: var(--accent-green); border-color: var(--accent-green); color: #000; }
.page-item.disabled .page-link { background-color: transparent; }
/* ── Login-Seite ─────────────────────────────────────────── */
body .card.shadow-lg {
background: #1e2230;
border: 1px solid #2a2f42;
}
/* ── Scrollbars ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
/* ── Diverse ─────────────────────────────────────────────── */
.blink {
animation: blink-anim 1.5s infinite;
}
@keyframes blink-anim {
0%, 100% { opacity: 1; }
50% { opacity: .2; }
}
.font-monospace { font-family: 'Consolas', 'Cascadia Code', monospace !important; }
.text-truncate { max-width: 250px; }
/* Chart.js Canvas */
canvas { max-height: 250px; }
/* Alert */
.alert { font-size: .85rem; }
/* sticky-top in dark scrollable containers */
.sticky-top { z-index: 1; }

97
web/static/js/main.js Normal file
View File

@@ -0,0 +1,97 @@
/* ============================================================
MCLogger main.js
Author: SimolZimol
============================================================ */
// ── Sidebar Toggle ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('sidebar');
if (btn && sidebar) {
btn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
});
// Zustand beim Laden wiederherstellen
if (localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
}
}
// Online-Count aktualisieren
updateOnlineCount();
setInterval(updateOnlineCount, 30_000);
// Tooltips initialisieren
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
// Automatisch Tabellen sortieren ermöglichen
initTableSort();
});
// ── Online-Count API ──────────────────────────────────────
function updateOnlineCount() {
fetch('/api/online')
.then(r => r.json())
.then(data => {
const el = document.getElementById('online-count');
if (el) el.textContent = data.length;
})
.catch(() => {/* Ignorieren wenn nicht eingeloggt */});
}
// ── Einfache Tabellen-Sortierung ──────────────────────────
function initTableSort() {
document.querySelectorAll('th[data-sort]').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const table = th.closest('table');
const idx = Array.from(th.parentNode.children).indexOf(th);
const asc = th.dataset.order !== 'asc';
th.dataset.order = asc ? 'asc' : 'desc';
const rows = Array.from(table.querySelectorAll('tbody tr'));
rows.sort((a, b) => {
const av = a.cells[idx]?.textContent.trim() ?? '';
const bv = b.cells[idx]?.textContent.trim() ?? '';
return asc ? av.localeCompare(bv, 'de', {numeric: true}) : bv.localeCompare(av, 'de', {numeric: true});
});
const tbody = table.querySelector('tbody');
rows.forEach(r => tbody.appendChild(r));
});
});
}
// ── Formatierung ──────────────────────────────────────────
function fmtDuration(sec) {
sec = parseInt(sec) || 0;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
// ── Live-Chat-Reload (optional) ───────────────────────────
if (window.location.pathname === '/chat') {
// Kein automatisches Reload im Chat (würde Filter zurücksetzen)
}
// ── Kopieren in Zwischenablage ────────────────────────────
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const target = document.querySelector(btn.dataset.target);
if (target) {
navigator.clipboard.writeText(target.textContent.trim())
.then(() => {
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(() => btn.innerHTML = '<i class="bi bi-clipboard"></i>', 1500);
});
}
});
});

View File

@@ -0,0 +1,22 @@
{% if pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center flex-wrap">
<li class="page-item {{ 'disabled' if page == 1 }}">
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page-1, 1]|max)) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% for p in range([1, page-3]|max, [pages+1, page+4]|min) %}
<li class="page-item {{ 'active' if p == page }}">
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=p)) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {{ 'disabled' if page == pages }}">
<a class="page-link" href="{{ url_for(request.endpoint, **dict(request.args, page=[page+1, pages]|min)) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
<p class="text-center text-muted small">Page {{ page }} of {{ pages }} · {{ total }} entries</p>
</nav>
{% endif %}

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Site Admin{% endblock %} — MCLogger</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-dark bg-danger">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="{{ url_for('site_admin.dashboard') }}">
<i class="bi bi-shield-fill-check me-2"></i>MCLogger — Site Admin
</a>
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('site_admin.dashboard') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.dashboard' }}">Dashboard</a>
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Gruppen</a>
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Benutzer</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
</div>
</nav>
<div class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,121 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-shield-fill-check text-danger me-2"></i>Site Admin Dashboard</h2>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-danger">{{ stats.group_count }}</div>
<div class="text-muted">Gruppen</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-warning">{{ stats.user_count }}</div>
<div class="text-muted">Benutzer</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-success">{{ stats.db_configured }}</div>
<div class="text-muted">DBs konfiguriert</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
<div class="text-muted">Site Admins</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card border-secondary">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-collection-fill me-2"></i>Gruppen</span>
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg"></i> Neu
</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>Name</th><th>Mitglieder</th><th>DB</th><th></th></tr></thead>
<tbody>
{% for g in groups %}
<tr>
<td>{{ g.name }}</td>
<td>{{ g.member_count }}</td>
<td>
{% if g.has_db %}
<span class="badge bg-success">Konfiguriert</span>
{% else %}
<span class="badge bg-secondary">Keine</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Browse">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted text-center py-3">Keine Gruppen vorhanden</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer text-end">
<a href="{{ url_for('site_admin.groups') }}" class="text-muted small">Alle Gruppen →</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-secondary">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-people-fill me-2"></i>Benutzer</span>
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg"></i> Neu
</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>Benutzer</th><th>Gruppen</th><th>Admin</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.group_count }}</td>
<td>{% if u.is_site_admin %}<span class="badge bg-danger"><i class="bi bi-shield-fill"></i></span>{% endif %}</td>
<td class="text-end">
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted text-center py-3">Keine Benutzer vorhanden</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer text-end">
<a href="{{ url_for('site_admin.users') }}" class="text-muted small">Alle Benutzer →</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base.html" %}
{% block title %}{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe' }}{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-4">
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h2 class="mb-0">{{ 'Gruppe bearbeiten' if group else 'Neue Gruppe erstellen' }}</h2>
</div>
<div class="row">
<div class="col-md-6">
<div class="card border-secondary">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Gruppenname *</label>
<input type="text" name="name" class="form-control" required
value="{{ group.name if group else request.form.get('name', '') }}">
</div>
<div class="mb-4">
<label class="form-label">Beschreibung</label>
<textarea name="description" class="form-control" rows="3">{{ group.description if group else request.form.get('description', '') }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if group else 'Erstellen' }}
</button>
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-outline-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "admin/base.html" %}
{% block title %}Mitglieder {{ group.name }}{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-4">
<a href="{{ url_for('site_admin.groups') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h2 class="mb-0">Mitglieder: <span class="text-success">{{ group.name }}</span></h2>
</div>
<div class="row g-3">
<!-- Aktuelle Mitglieder -->
<div class="col-md-7">
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-people-fill me-2"></i>Aktuelle Mitglieder ({{ members|length }})</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
<tbody>
{% for m in members %}
<tr>
<td>{{ m.username }}</td>
<td>
{% if m.role == 'admin' %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
{% else %}
<span class="badge bg-secondary">Member</span>
{% endif %}
</td>
<td class="text-end">
<form method="post" action="{{ url_for('site_admin.group_member_toggle_role', group_id=group.id, user_id=m.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Rolle wechseln">
<i class="bi bi-arrow-left-right"></i>
</button>
</form>
<form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline"
onsubmit="return confirm('{{ m.username }} aus Gruppe entfernen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
<i class="bi bi-person-dash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Benutzer hinzufügen -->
<div class="col-md-5">
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Benutzer hinzufügen</div>
<div class="card-body">
{% if non_members %}
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
<div class="mb-3">
<label class="form-label">Benutzer auswählen</label>
<select name="user_id" class="form-select">
{% for u in non_members %}
<option value="{{ u.id }}">{{ u.username }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Rolle</label>
<select name="role" class="form-select">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-person-plus-fill me-1"></i>Hinzufügen
</button>
</form>
{% else %}
<p class="text-muted text-center py-3">Alle Benutzer sind bereits Mitglied.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends "admin/base.html" %}
{% block title %}Gruppen{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-collection-fill me-2"></i>Gruppen</h2>
<a href="{{ url_for('site_admin.group_create') }}" class="btn btn-success">
<i class="bi bi-plus-lg me-1"></i>Neue Gruppe
</a>
</div>
<div class="card border-secondary">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>ID</th><th>Name</th><th>Beschreibung</th><th>Mitglieder</th><th>DB</th><th>Erstellt</th><th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{% for g in groups %}
<tr>
<td class="text-muted small">{{ g.id }}</td>
<td class="fw-semibold">{{ g.name }}</td>
<td class="text-muted small">{{ g.description or '—' }}</td>
<td>{{ g.member_count }}</td>
<td>
{% if g.has_db %}
<span class="badge bg-success"><i class="bi bi-database-fill-check me-1"></i>Konfiguriert</span>
{% else %}
<span class="badge bg-secondary">Keine DB</span>
{% endif %}
</td>
<td class="text-muted small">{{ g.created_at | fmt_dt }}</td>
<td class="text-end">
<a href="{{ url_for('site_admin.view_group', group_id=g.id) }}" class="btn btn-sm btn-outline-info" title="Daten browsen">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('site_admin.group_members', group_id=g.id) }}" class="btn btn-sm btn-outline-secondary" title="Mitglieder">
<i class="bi bi-people-fill"></i>
</a>
<a href="{{ url_for('site_admin.group_edit', group_id=g.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</a>
<form method="post" action="{{ url_for('site_admin.group_delete', group_id=g.id) }}" class="d-inline"
onsubmit="return confirm('Gruppe {{ g.name }} löschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="bi bi-trash3"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-muted text-center py-4">Noch keine Gruppen vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "admin/base.html" %}
{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }}{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-4">
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h2 class="mb-0">{{ 'Benutzer bearbeiten: ' ~ user.username if user else 'Neuer Benutzer' }}</h2>
</div>
<div class="row">
<div class="col-md-6">
<div class="card border-secondary">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername *</label>
<input type="text" name="username" class="form-control" required
value="{{ user.username if user else request.form.get('username', '') }}">
</div>
<div class="mb-3">
<label class="form-label">{{ 'Neues Passwort (leer lassen = unverändert)' if user else 'Passwort *' }}</label>
<input type="password" name="password" class="form-control"
{{ '' if user else 'required' }}>
{% if not user %}
<div class="form-text">Mindestens 8 Zeichen empfohlen.</div>
{% endif %}
</div>
<div class="mb-4">
<div class="form-check">
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
value="1" {{ 'checked' if user and user.is_site_admin }}>
<label class="form-check-label" for="is_site_admin">
<span class="text-danger fw-semibold"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
<small class="text-muted d-block">Voller Zugriff auf alle Gruppen und Einstellungen</small>
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{{ 'Speichern' if user else 'Erstellen' }}
</button>
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "admin/base.html" %}
{% block title %}Benutzer{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people-fill me-2"></i>Benutzer</h2>
<a href="{{ url_for('site_admin.user_create') }}" class="btn btn-success">
<i class="bi bi-person-plus-fill me-1"></i>Neuer Benutzer
</a>
</div>
<div class="card border-secondary">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr><th>Benutzer</th><th>Gruppen</th><th>Site Admin</th><th>Erstellt</th><th class="text-end">Aktionen</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">{{ u.username }}</td>
<td>
{% for g in u.groups %}
<span class="badge bg-secondary me-1">{{ g.name }}
{% if g.role == 'admin' %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
</span>
{% else %}<span class="text-muted small">Keine</span>{% endfor %}
</td>
<td>
{% if u.is_site_admin %}
<span class="badge bg-danger"><i class="bi bi-shield-fill me-1"></i>Site Admin</span>
{% else %}—{% endif %}
</td>
<td class="text-muted small">{{ u.created_at | fmt_dt }}</td>
<td class="text-end">
<a href="{{ url_for('site_admin.user_edit', user_id=u.id) }}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</a>
<form method="post" action="{{ url_for('site_admin.user_delete', user_id=u.id) }}" class="d-inline"
onsubmit="return confirm('Benutzer {{ u.username }} löschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="bi bi-trash3"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-muted text-center py-4">Keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCLogger Site Admin Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
.login-card { width: 100%; max-width: 400px; }
</style>
</head>
<body>
<div class="login-card p-4">
<div class="text-center mb-4">
<i class="bi bi-shield-fill-check fs-1 text-danger"></i>
<h3 class="fw-bold mt-2">Site Admin</h3>
<p class="text-muted small">Administrativer Zugang</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="card border-danger">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<div class="input-group">
<span class="input-group-text text-danger"><i class="bi bi-person-fill"></i></span>
<input type="text" name="username" class="form-control" required autofocus
value="{{ request.form.get('username', '') }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<div class="input-group">
<span class="input-group-text text-danger"><i class="bi bi-lock-fill"></i></span>
<input type="password" name="password" class="form-control" required>
</div>
</div>
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-shield-lock-fill me-1"></i> Admin Login
</button>
</form>
</div>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('auth.login') }}" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Zurück zum normalen Login
</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCLogger Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
.login-card { width: 100%; max-width: 400px; }
</style>
</head>
<body>
<div class="login-card p-4">
<div class="text-center mb-4">
<i class="bi bi-database-fill-gear fs-1 text-success"></i>
<h3 class="fw-bold mt-2">MCLogger</h3>
<p class="text-muted small">Panel Login</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="card border-secondary">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
<input type="text" name="username" class="form-control" required autofocus
value="{{ request.form.get('username', '') }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input type="password" name="password" class="form-control" required>
</div>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-box-arrow-in-right me-1"></i> Einloggen
</button>
</form>
</div>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('auth.admin_login') }}" class="text-muted small">
<i class="bi bi-shield-fill me-1"></i>Site Admin Login
</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

173
web/templates/base.html Normal file
View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MCLogger{% endblock %} — Panel</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="d-flex" id="wrapper">
<!-- ── Sidebar ─────────────────────────────────────────── -->
<nav id="sidebar" class="d-flex flex-column p-3">
<div class="sidebar-brand mb-3 text-center">
<i class="bi bi-database-fill-gear fs-2 text-success"></i>
<div class="fw-bold mt-1">MCLogger</div>
{% if session.get('group_name') %}
<span class="badge bg-success mt-1">{{ session.group_name }}</span>
{% endif %}
</div>
{% set perms = session.get('permissions', {}) %}
{% set is_admin = session.get('is_site_admin') or session.get('role') == 'admin' %}
<ul class="nav flex-column gap-1">
{% if perms.get('view_dashboard', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.dashboard' }}" href="{{ url_for('panel.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
{% endif %}
{% if perms.get('view_players', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint in ['panel.players','panel.player_detail'] }}" href="{{ url_for('panel.players') }}">
<i class="bi bi-people-fill"></i> Players
</a>
</li>
{% endif %}
{% if perms.get('view_sessions', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.sessions' }}" href="{{ url_for('panel.sessions') }}">
<i class="bi bi-clock-history"></i> Sessions
</a>
</li>
{% endif %}
{% if perms.get('view_chat', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.chat' }}" href="{{ url_for('panel.chat') }}">
<i class="bi bi-chat-dots-fill"></i> Chat
</a>
</li>
{% endif %}
{% if perms.get('view_commands', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.commands' }}" href="{{ url_for('panel.commands') }}">
<i class="bi bi-terminal-fill"></i> Commands
</a>
</li>
{% endif %}
{% if perms.get('view_deaths', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.deaths' }}" href="{{ url_for('panel.deaths') }}">
<i class="bi bi-heartbreak-fill"></i> Deaths
</a>
</li>
{% endif %}
{% if perms.get('view_blocks', True) or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.blocks' }}" href="{{ url_for('panel.blocks') }}">
<i class="bi bi-bricks"></i> Block Events
</a>
</li>
{% endif %}
{% if perms.get('view_proxy') or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.proxy' }}" href="{{ url_for('panel.proxy') }}">
<i class="bi bi-diagram-3-fill"></i> Proxy Events
</a>
</li>
{% endif %}
{% if perms.get('view_server_events') or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.server_events' }}" href="{{ url_for('panel.server_events') }}">
<i class="bi bi-server"></i> Server Events
</a>
</li>
{% endif %}
{% if perms.get('view_perms') or is_admin %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'panel.perms' }}" href="{{ url_for('panel.perms') }}">
<i class="bi bi-shield-lock-fill"></i> Permissions
</a>
</li>
{% endif %}
</ul>
<hr class="my-2">
<!-- Gruppen-Switcher -->
{% if not session.get('is_site_admin') and user_groups and user_groups|length > 1 %}
<div class="mb-2">
<small class="text-muted">Gruppe wechseln:</small>
{% for g in user_groups %}
<a href="{{ url_for('auth.switch_group', group_id=g.id) }}"
class="btn btn-sm w-100 mt-1 {{ 'btn-success' if g.id == session.get('group_id') else 'btn-outline-secondary' }}">
{{ g.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Admin-Links -->
{% if session.get('role') == 'admin' and not session.get('is_site_admin') %}
<a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1">
<i class="bi bi-gear-fill"></i> Gruppe verwalten
</a>
{% endif %}
{% if session.get('is_site_admin') %}
{% if session.get('admin_viewing') %}
<a href="{{ url_for('site_admin.stop_view') }}" class="btn btn-warning btn-sm mb-1">
<i class="bi bi-arrow-left"></i> Zurück zum Admin
</a>
{% else %}
<a href="{{ url_for('site_admin.dashboard') }}" class="btn btn-outline-danger btn-sm mb-1">
<i class="bi bi-shield-fill"></i> Site Admin
</a>
{% endif %}
{% endif %}
<div class="mt-auto">
<div class="small text-muted mb-1">
<i class="bi bi-circle-fill text-success me-1" style="font-size:.5rem"></i>
<span id="online-count"></span> Online
</div>
<small class="text-muted d-block mb-1">{{ session.get('username', '') }}</small>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger btn-sm w-100">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
</nav>
<!-- ── Hauptinhalt ───────────────────────────────────── -->
<div id="page-content" class="flex-grow-1 overflow-auto">
<div class="topbar d-flex align-items-center justify-content-between px-4 py-2">
<button class="btn btn-sm btn-outline-secondary" id="sidebarToggle"><i class="bi bi-list"></i></button>
<h6 class="mb-0 fw-semibold">{% block page_title %}{% endblock %}</h6>
<small class="text-muted">{{ now.strftime('%d.%m.%Y %H:%M') }}</small>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="px-4 pt-2">
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="px-4 py-3">{% block content %}{% endblock %}</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

69
web/templates/blocks.html Normal file
View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Block-Events{% endblock %}
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-2">
<select name="type" class="form-select form-select-sm">
<option value="">All Types</option>
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
</div>
<div class="col-md-2">
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
</div>
<div class="col-md-2">
<select name="world" class="form-select form-select-sm">
<option value="">All Worlds</option>
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="server" class="form-select form-select-sm">
<option value="">All Servers</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} block events</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td>
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
</td>
<td class="small">{{ r.player_name or '—' }}</td>
<td class="small font-monospace">{{ r.block_type }}</td>
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
<td class="small">{{ r.tool or '—' }}</td>
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

58
web/templates/chat.html Normal file
View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Chat Log{% endblock %}
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-3">
<label class="form-label small">Message</label>
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small">Server</label>
<select name="server" class="form-select form-select-sm">
<option value="">Alle</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-12 col-md-2">
<label class="form-label small">From</label>
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small">To</label>
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} messages</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
<td class="small">{{ r.message }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Commands{% endblock %}
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
</div>
<div class="col-md-3">
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
</div>
<div class="col-md-2">
<select name="server" class="form-select form-select-sm">
<option value="">All Servers</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} commands</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
<td class="small font-monospace text-warning">{{ r.command }}</td>
<td class="small text-muted">
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
{% block content %}
<!-- ── Statistik-Karten ────────────────────────────────── -->
<div class="row g-3 mb-4">
{% set cards = [
('Total Players', stats.players_total, 'bi-people-fill', 'success'),
('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'),
('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'),
('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'),
('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'),
('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'),
('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'),
('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'),
] %}
{% for label, value, icon, color in cards %}
<div class="col-6 col-md-3 col-xl-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-{{ color }} bg-opacity-25">
<i class="bi {{ icon }} text-{{ color }}"></i>
</div>
<div>
<div class="stat-value">{{ value | int }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
<div class="row g-3 mb-4">
<!-- Online-Spieler -->
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div id="online-table">
{% if online %}
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Player</th><th>Server</th><th>Country</th><th>Since</th></tr>
</thead>
<tbody>
{% for s in online %}
<tr>
<td><a href="{{ url_for('player_detail', uuid='') }}">{{ s.username }}</a></td>
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
<td class="small text-muted">{{ s.country or '—' }}</td>
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-moon-stars-fill fs-3"></i><br>
No players online
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Letzte Aktivität -->
<div class="col-12 col-lg-8">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-activity me-2"></i>Last 24h Activity
</div>
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark sticky-top">
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
</thead>
<tbody>
{% for r in recent %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td>
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
</td>
<td class="small">{{ r.player_name or '—' }}</td>
<td class="small">{{ r.server_name or '—' }}</td>
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
<div class="row g-3 mb-4">
<!-- Block-Events Chart -->
<div class="col-12 col-md-6">
<div class="card">
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
<div class="card-body">
<canvas id="blockChart" height="200"></canvas>
</div>
</div>
</div>
<!-- Todesursachen -->
<div class="col-12 col-md-3">
<div class="card">
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
<div class="card-body">
<canvas id="deathChart" height="200"></canvas>
</div>
</div>
</div>
<!-- Top Spieler -->
<div class="col-12 col-md-3">
<div class="card">
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
<table class="table table-sm mb-0">
<tbody>
{% for p in top_players %}
<tr>
<td>{{ loop.index }}. {{ p.username }}</td>
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
<div class="row g-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<i class="bi bi-server me-2"></i>Server Events (last 24h)
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
<tbody>
{% for e in server_events %}
<tr>
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
<td class="small">{{ e.server_name }}</td>
<td class="small">{{ e.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Block-Chart
const blockCtx = document.getElementById('blockChart');
new Chart(blockCtx, {
type: 'bar',
data: {
labels: {{ block_chart | map(attribute='day') | list | tojson }},
datasets: [{
label: 'Block-Events',
data: {{ block_chart | map(attribute='cnt') | list | tojson }},
backgroundColor: 'rgba(25,135,84,0.7)',
borderColor: 'rgba(25,135,84,1)',
borderWidth: 1,
}]
},
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
// Todesursachen-Chart
const deathCtx = document.getElementById('deathChart');
new Chart(deathCtx, {
type: 'doughnut',
data: {
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
datasets: [{
data: {{ death_causes | map(attribute='cnt') | list | tojson }},
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'],
}]
},
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
});
// Live Online-Count aktualisieren
function refreshOnline() {
fetch('/api/online')
.then(r => r.json())
.then(data => {
document.getElementById('online-count').textContent = data.length;
});
}
setInterval(refreshOnline, 30000);
refreshOnline();
</script>
{% endblock %}

49
web/templates/deaths.html Normal file
View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Deaths{% endblock %}
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
</div>
<div class="col-md-3">
<select name="cause" class="form-select form-select-sm">
<option value="">All Causes</option>
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} deaths</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name }}</td>
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
<td class="small">{{ r.killer_name or '—' }}</td>
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
<td class="small">{{ r.exp_level }}</td>
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
<td class="small text-muted">{{ r.death_message or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Gruppen Admin{% endblock %} — {{ session.get('group_name','') }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-dark bg-warning bg-opacity-75">
<div class="container-fluid">
<a class="navbar-brand fw-bold text-dark" href="{{ url_for('group_admin.dashboard') }}">
<i class="bi bi-gear-fill me-2"></i>{{ session.get('group_name', 'Gruppe') }} — Admin
</a>
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('group_admin.dashboard') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.dashboard' }}">Dashboard</a>
<a href="{{ url_for('group_admin.members') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.members' }}">Mitglieder</a>
<a href="{{ url_for('group_admin.database') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.database' }}">Datenbank</a>
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
<i class="bi bi-grid me-1"></i>Panel
</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-dark btn-sm">
<i class="bi bi-box-arrow-right"></i>
</a>
</div>
</div>
</nav>
<div class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,77 @@
{% extends "group_admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-gear-fill text-warning me-2"></i>Gruppenadmin: {{ session.get('group_name') }}</h2>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-warning">{{ stats.member_count }}</div>
<div class="text-muted">Mitglieder</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold {{ 'text-success' if stats.db_configured else 'text-danger' }}">
{{ 'Ja' if stats.db_configured else 'Nein' }}
</div>
<div class="text-muted">DB konfiguriert</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold text-info">{{ stats.admin_count }}</div>
<div class="text-muted">Admins</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card border-secondary h-100">
<div class="card-header">
<i class="bi bi-people-fill me-2"></i>Schnellzugriff
</div>
<div class="card-body d-flex flex-column gap-2">
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-warning">
<i class="bi bi-people-fill me-2"></i>Mitglieder verwalten
</a>
<a href="{{ url_for('group_admin.database') }}" class="btn btn-outline-info">
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
</a>
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-success">
<i class="bi bi-speedometer2 me-2"></i>Panel öffnen
</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-secondary h-100">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Gruppeninfo</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Name</dt>
<dd class="col-sm-7">{{ session.get('group_name') }}</dd>
<dt class="col-sm-5">Deine Rolle</dt>
<dd class="col-sm-7"><span class="badge bg-warning text-dark">Admin</span></dd>
<dt class="col-sm-5">Datenbank</dt>
<dd class="col-sm-7">
{% if stats.db_configured %}
<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Verbunden</span>
{% else %}
<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Nicht konfiguriert</span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "group_admin/base.html" %}
{% block title %}Datenbank{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-database-fill-gear me-2"></i>MC Datenbank konfigurieren</h2>
<div class="row g-3">
<div class="col-md-7">
<div class="card border-secondary">
<div class="card-header">Verbindungsdaten</div>
<div class="card-body">
{% if test_result is defined %}
<div class="alert {{ 'alert-success' if test_result else 'alert-danger' }}">
{% if test_result %}
<i class="bi bi-check-circle-fill me-2"></i>Verbindung erfolgreich! Daten wurden gespeichert.
{% else %}
<i class="bi bi-x-circle-fill me-2"></i>Verbindung fehlgeschlagen: {{ test_error }}
{% endif %}
</div>
{% endif %}
<form method="post">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Host *</label>
<input type="text" name="host" class="form-control" required
placeholder="localhost"
value="{{ creds.host if creds else request.form.get('host', '') }}">
</div>
<div class="col-md-4">
<label class="form-label">Port *</label>
<input type="number" name="port" class="form-control" required
value="{{ creds.port if creds else request.form.get('port', '3306') }}">
</div>
<div class="col-12">
<label class="form-label">Datenbank *</label>
<input type="text" name="database" class="form-control" required
placeholder="mclogger"
value="{{ creds.database if creds else request.form.get('database', '') }}">
</div>
<div class="col-md-6">
<label class="form-label">Benutzer *</label>
<input type="text" name="user" class="form-control" required
value="{{ creds.user if creds else request.form.get('user', '') }}">
</div>
<div class="col-md-6">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control"
placeholder="{{ '(unverändert)' if creds else '' }}">
{% if creds %}
<div class="form-text">Leer lassen um das bestehende Passwort beizubehalten.</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" name="action" value="test_save" class="btn btn-success">
<i class="bi bi-plug-fill me-1"></i>Testen & Speichern
</button>
{% if creds %}
<button type="submit" name="action" value="delete" class="btn btn-outline-danger"
onclick="return confirm('DB-Konfiguration löschen?')">
<i class="bi bi-trash3 me-1"></i>Entfernen
</button>
{% endif %}
</div>
</form>
</div>
</div>
</div>
<div class="col-md-5">
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Info</div>
<div class="card-body">
<p class="small text-muted">
Gib hier die Verbindungsdaten zu deiner <strong>MCLogger MySQL-Datenbank</strong> ein.
Das Panel liest nur Daten (SELECT) — schreibender Zugriff ist nicht nötig.
</p>
<p class="small text-muted">
Die Zugangsdaten werden <strong>verschlüsselt</strong> gespeichert und sind nur für deine Gruppe sichtbar.
</p>
<hr>
<p class="small text-muted mb-1"><strong>Benötigte Tabellen:</strong></p>
<ul class="small text-muted">
<li>player_sessions</li>
<li>chat_messages</li>
<li>player_commands</li>
<li>block_events</li>
<li>player_deaths</li>
<li>proxy_events</li>
<li>server_events</li>
<li>permission_changes</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "group_admin/base.html" %}
{% block title %}Berechtigungen {{ member.username }}{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-4">
<a href="{{ url_for('group_admin.members') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h2 class="mb-0">Berechtigungen: <span class="text-warning">{{ member.username }}</span></h2>
</div>
<div class="row">
<div class="col-md-7">
<div class="card border-secondary">
<div class="card-header">
<i class="bi bi-shield-lock-fill me-2"></i>Panel-Berechtigungen
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Rolle</label>
<select name="role" class="form-select">
<option value="member" {{ 'selected' if member.role == 'member' }}>Member</option>
<option value="admin" {{ 'selected' if member.role == 'admin' }}>Admin</option>
</select>
<div class="form-text">Admins können Mitglieder und die DB-Verbindung verwalten.</div>
</div>
<hr>
<p class="form-label mb-2">Panel-Zugriff</p>
<div class="row g-2">
{% for key, label in all_permissions %}
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="perm_{{ key }}" id="perm_{{ key }}"
{{ 'checked' if perms.get(key, True) }}>
<label class="form-check-label" for="perm_{{ key }}">{{ label }}</label>
</div>
</div>
{% endfor %}
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-warning">
<i class="bi bi-check-lg me-1"></i>Speichern
</button>
<a href="{{ url_for('group_admin.members') }}" class="btn btn-outline-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "group_admin/base.html" %}
{% block title %}Mitglieder{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-people-fill me-2"></i>Mitglieder</h2>
<div class="row g-3">
<!-- Mitgliederliste -->
<div class="col-md-8">
<div class="card border-secondary">
<div class="card-header">Aktuelle Mitglieder ({{ members|length }})</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>Benutzer</th><th>Rolle</th><th class="text-end">Aktionen</th></tr></thead>
<tbody>
{% for m in members %}
<tr>
<td>{{ m.username }}</td>
<td>
{% if m.role == 'admin' %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
{% else %}
<span class="badge bg-secondary">Member</span>
{% endif %}
</td>
<td class="text-end">
{% if m.id != session.get('user_id') %}
<a href="{{ url_for('group_admin.member_edit', user_id=m.id) }}" class="btn btn-sm btn-outline-warning" title="Berechtigungen">
<i class="bi bi-shield-lock"></i>
</a>
<form method="post" action="{{ url_for('group_admin.member_remove', user_id=m.id) }}" class="d-inline"
onsubmit="return confirm('{{ m.username }} entfernen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Entfernen">
<i class="bi bi-person-dash"></i>
</button>
</form>
{% else %}
<span class="text-muted small">Du</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted text-center py-3">Keine Mitglieder</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Benutzer einladen (nur via Benutzername - Site Admin fügt Benutzer hinzu, Gruppen admin kann nur bestehende Mitglieder verwalten) -->
<div class="col-md-4">
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Hinweis</div>
<div class="card-body">
<p class="text-muted small">
Neue Mitglieder müssen vom <strong>Site Admin</strong> zur Gruppe hinzugefügt werden.
</p>
<p class="text-muted small">
Als Gruppenadmin kannst du Berechtigungen bestehender Mitglieder verwalten und Mitglieder entfernen.
</p>
</div>
</div>
</div>
</div>
{% endblock %}

49
web/templates/login.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCLogger Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="d-flex align-items-center justify-content-center min-vh-100 bg-dark">
<div class="card shadow-lg" style="width: 380px;">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-database-fill-gear text-success" style="font-size: 3rem;"></i>
<h4 class="mt-2 fw-bold">MCLogger</h4>
<p class="text-muted small">Admin-Interface · SimolZimol</p>
</div>
{% if error %}
<div class="alert alert-danger py-2">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
<input type="text" name="username" class="form-control" placeholder="admin" required autofocus>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
</div>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-box-arrow-in-right me-1"></i>Login
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Block-Events{% endblock %}
{% block page_title %}<i class="bi bi-bricks me-2"></i>Block-Events{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-2">
<select name="type" class="form-select form-select-sm">
<option value="">All Types</option>
{% for t in ['break','place','ignite','burn','explode','fade','grow','dispense'] %}
<option {{ 'selected' if t == event_type }}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Spieler…" value="{{ player }}">
</div>
<div class="col-md-2">
<input type="text" name="block" class="form-control form-control-sm" placeholder="Block-Typ…" value="{{ block }}">
</div>
<div class="col-md-2">
<select name="world" class="form-select form-select-sm">
<option value="">All Worlds</option>
{% for w in worlds %}<option {{ 'selected' if w == world }}>{{ w }}</option>{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="server" class="form-select form-select-sm">
<option value="">All Servers</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('panel.blocks') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} block events</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Type</th><th>Player</th><th>Block</th><th>World</th><th>Position</th><th>Tool</th><th>Silk</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td>
{% set colors = {'break':'danger','place':'success','ignite':'warning','burn':'orange','explode':'dark'} %}
<span class="badge bg-{{ colors.get(r.event_type,'secondary') }}">{{ r.event_type }}</span>
</td>
<td class="small">{{ r.player_name or '—' }}</td>
<td class="small font-monospace">{{ r.block_type }}</td>
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
<td class="small text-muted">{{ r.x }}, {{ r.y }}, {{ r.z }}</td>
<td class="small">{{ r.tool or '—' }}</td>
<td>{% if r.is_silk %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No block events</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Chat Log{% endblock %}
{% block page_title %}<i class="bi bi-chat-dots-fill me-2"></i>Chat Log{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-3">
<label class="form-label small">Message</label>
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search…" value="{{ search }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small">Server</label>
<select name="server" class="form-select form-select-sm">
<option value="">Alle</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-12 col-md-2">
<label class="form-label small">From</label>
<input type="date" name="from" class="form-control form-control-sm" value="{{ date_from }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small">To</label>
<input type="date" name="to" class="form-control form-control-sm" value="{{ date_to }}">
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('panel.chat') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} messages</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Server</th><th>Channel</th><th>Message</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
<td><span class="badge bg-primary">{{ r.channel or 'global' }}</span></td>
<td class="small">{{ r.message }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No messages found</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Commands{% endblock %}
{% block page_title %}<i class="bi bi-terminal-fill me-2"></i>Commands{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
</div>
<div class="col-md-3">
<input type="text" name="q" class="form-control form-control-sm" placeholder="Command text…" value="{{ search }}">
</div>
<div class="col-md-2">
<select name="server" class="form-select form-select-sm">
<option value="">All Servers</option>
{% for s in servers %}<option {{ 'selected' if s == server }}>{{ s }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('panel.commands') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} commands</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Server</th><th>Command</th><th>Position</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
<td><span class="badge bg-secondary">{{ r.server_name or '—' }}</span></td>
<td class="small font-monospace text-warning">{{ r.command }}</td>
<td class="small text-muted">
{% if r.world %}{{ r.world }} ({{ r.x|round(0)|int }}, {{ r.y|round(0)|int }}, {{ r.z|round(0)|int }}){% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No commands</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}<i class="bi bi-speedometer2 me-2"></i>Dashboard{% endblock %}
{% block content %}
<!-- ── Statistik-Karten ────────────────────────────────── -->
<div class="row g-3 mb-4">
{% set cards = [
('Total Players', stats.players_total, 'bi-people-fill', 'success'),
('Sessions Today', stats.sessions_today, 'bi-clock-history', 'info'),
('Chats Today', stats.chat_today, 'bi-chat-dots-fill', 'primary'),
('Commands Today', stats.commands_today, 'bi-terminal-fill', 'warning'),
('Blocks Today', stats.blocks_today, 'bi-bricks', 'secondary'),
('Deaths Today', stats.deaths_today, 'bi-heartbreak-fill', 'danger'),
('Entity Events', stats.entity_events_today, 'bi-bug-fill', 'light'),
('Proxy Events', stats.proxy_events_today, 'bi-diagram-3-fill', 'dark'),
] %}
{% for label, value, icon, color in cards %}
<div class="col-6 col-md-3 col-xl-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-{{ color }} bg-opacity-25">
<i class="bi {{ icon }} text-{{ color }}"></i>
</div>
<div>
<div class="stat-value">{{ value | int }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- ── Zeile 2: Online-Spieler + Letzte Aktivität ────── -->
<div class="row g-3 mb-4">
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-circle-fill text-success me-2 blink" style="font-size:.5rem"></i>Online Players</span>
<button class="btn btn-sm btn-outline-secondary" onclick="refreshOnline()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div id="online-table">
{% if online %}
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Player</th><th>Server</th><th>Since</th></tr>
</thead>
<tbody>
{% for s in online %}
<tr>
<td><a href="{{ url_for('panel.player_detail', uuid=s.get('player_uuid','')) }}">{{ s.player_name }}</a></td>
<td><span class="badge bg-secondary">{{ s.server_name }}</span></td>
<td class="small text-muted">{{ s.login_time | fmt_dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-moon-stars-fill fs-3"></i><br>No players online
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-activity me-2"></i>Last 24h Activity
</div>
<div class="card-body p-0" style="overflow-y:auto; max-height:320px;">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark sticky-top">
<tr><th>Time</th><th>Type</th><th>Player</th><th>Server</th><th>Detail</th></tr>
</thead>
<tbody>
{% for r in recent %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td>
{% set badge = {'chat':'primary','command':'warning','block':'secondary','death':'danger'} %}
<span class="badge bg-{{ badge.get(r.source,'light') }}">{{ r.source }}</span>
</td>
<td class="small">{{ r.player_name or '—' }}</td>
<td class="small">{{ r.server_name or '—' }}</td>
<td class="small text-truncate" style="max-width:200px;" title="{{ r.detail }}">{{ r.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── Zeile 3: Charts ────────────────────────────────── -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<div class="card">
<div class="card-header"><i class="bi bi-bricks me-2"></i>Block Events (last 7 days)</div>
<div class="card-body">
<canvas id="blockChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-12 col-md-3">
<div class="card">
<div class="card-header"><i class="bi bi-heartbreak-fill me-2"></i>Death Causes (7d)</div>
<div class="card-body">
<canvas id="deathChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-12 col-md-3">
<div class="card">
<div class="card-header"><i class="bi bi-trophy-fill me-2"></i>Top Playtime</div>
<div class="card-body p-0" style="overflow-y:auto;max-height:240px;">
<table class="table table-sm mb-0">
<tbody>
{% for p in top_players %}
<tr>
<td>{{ loop.index }}. {{ p.username }}</td>
<td class="text-end text-muted small">{{ p.total_playtime_sec | fmt_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── Zeile 4: Server-Events ─────────────────────────── -->
<div class="row g-3">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-server me-2"></i>Server Events (last 24h)</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark"><tr><th>Time</th><th>Type</th><th>Server</th><th>Message</th></tr></thead>
<tbody>
{% for e in server_events %}
<tr>
<td class="small text-muted text-nowrap">{{ e.timestamp | fmt_dt }}</td>
<td><span class="badge bg-info text-dark">{{ e.event_type }}</span></td>
<td class="small">{{ e.server_name }}</td>
<td class="small">{{ e.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const blockCtx = document.getElementById('blockChart');
new Chart(blockCtx, {
type: 'bar',
data: {
labels: {{ block_chart | map(attribute='day') | list | tojson }},
datasets: [{ label: 'Block-Events', data: {{ block_chart | map(attribute='cnt') | list | tojson }},
backgroundColor: 'rgba(25,135,84,0.7)', borderColor: 'rgba(25,135,84,1)', borderWidth: 1 }]
},
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
const deathCtx = document.getElementById('deathChart');
new Chart(deathCtx, {
type: 'doughnut',
data: {
labels: {{ death_causes | map(attribute='cause') | list | tojson }},
datasets: [{ data: {{ death_causes | map(attribute='cnt') | list | tojson }},
backgroundColor: ['#dc3545','#fd7e14','#ffc107','#198754','#0dcaf0','#6f42c1','#d63384','#6c757d'] }]
},
options: { plugins: { legend: { position: 'bottom', labels: { font: { size:10 } } } } }
});
function refreshOnline() {
fetch('/api/online').then(r => r.json()).then(data => {
document.getElementById('online-count').textContent = data.length;
});
}
setInterval(refreshOnline, 30000);
refreshOnline();
</script>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Deaths{% endblock %}
{% block page_title %}<i class="bi bi-heartbreak-fill me-2"></i>Deaths{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Player…" value="{{ player }}">
</div>
<div class="col-md-3">
<select name="cause" class="form-select form-select-sm">
<option value="">All Causes</option>
{% for c in causes %}<option {{ 'selected' if c == cause }}>{{ c }}</option>{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('panel.deaths') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} deaths</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Player</th><th>Cause</th><th>Killer</th><th>Killer Type</th><th>Level</th><th>World</th><th>Death Message</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td class="small fw-semibold">{{ r.player_name }}</td>
<td><span class="badge bg-danger">{{ r.cause or '—' }}</span></td>
<td class="small">{{ r.killer_name or '—' }}</td>
<td class="small text-muted">{{ r.killer_type or '—' }}</td>
<td class="small">{{ r.exp_level }}</td>
<td><span class="badge bg-secondary">{{ r.world }}</span></td>
<td class="small text-muted">{{ r.death_message or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No deaths</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}No Database{% endblock %}
{% block page_title %}<i class="bi bi-database-fill-x me-2"></i>Keine Datenbank{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-6 text-center">
<i class="bi bi-database-fill-x display-1 text-muted mb-4"></i>
<h3 class="mb-3">Keine Datenbank konfiguriert</h3>
<p class="text-muted mb-4">
Für diese Gruppe ist noch keine MC-Datenbank eingerichtet.
{% if session.get('role') == 'admin' %}
Du kannst die Verbindung als Gruppen-Admin konfigurieren.
{% else %}
Bitte wende dich an deinen Gruppenadmin.
{% endif %}
</p>
{% if session.get('role') == 'admin' %}
<a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg">
<i class="bi bi-database-fill-gear me-2"></i>Datenbank konfigurieren
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Permissions{% endblock %}
{% block page_title %}<i class="bi bi-shield-lock-fill me-2"></i>Permissions{% endblock %}
{% block content %}
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<input type="text" name="player" class="form-control form-control-sm" placeholder="Target player…" value="{{ player }}">
</div>
<div class="col-md-2">
<select name="plugin" class="form-select form-select-sm">
<option value="">All Plugins</option>
{% for pl in plugins %}<option {{ 'selected' if pl == plugin_filter }}>{{ pl }}</option>{% endfor %}
</select>
</div>
<div class="col-md-3">
<input type="text" name="type" class="form-control form-control-sm" placeholder="Event type…" value="{{ etype }}">
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm">Filter</button>
<a href="{{ url_for('panel.perms') }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
</div>
</form>
<div class="card">
<div class="card-header">{{ total }} permission events</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr><th>Time</th><th>Plugin</th><th>Event Type</th><th>Target Player</th><th>Actor</th><th>Target Type</th><th>Target ID</th><th>Action</th><th>Server</th></tr>
</thead>
<tbody>
{% for r in rows %}
{% set badge_colors = {
'luckperms_permission_set': 'success',
'luckperms_permission_unset': 'danger',
'luckperms_parent_add': 'primary',
'luckperms_parent_remove': 'warning',
'luckperms_meta_set': 'info',
'luckperms_meta_unset': 'secondary',
'luckperms_group_create': 'light',
'luckperms_group_delete': 'dark',
} %}
<tr>
<td class="small text-muted text-nowrap">{{ r.timestamp | fmt_dt }}</td>
<td><span class="badge bg-secondary">{{ r.plugin_name or '—' }}</span></td>
<td><span class="badge bg-{{ badge_colors.get(r.event_type,'secondary') }} text-wrap text-start" style="font-size:.7rem;">{{ r.event_type }}</span></td>
<td class="small fw-semibold">{{ r.player_name or '—' }}</td>
<td class="small">{{ r.actor_name or '—' }}</td>
<td class="small text-muted">{{ r.target_type or '—' }}</td>
<td class="small text-muted text-truncate" style="max-width:120px;" title="{{ r.target_id }}">{{ r.target_id or '—' }}</td>
<td class="small text-truncate" style="max-width:200px;" title="{{ r.action }}">{{ r.action or '—' }}</td>
<td><span class="badge bg-dark">{{ r.server_name or '—' }}</span></td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-muted py-4">No permission events found</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}{{ player.username }}{% endblock %}
{% block page_title %}<i class="bi bi-person-fill me-2"></i>{{ player.username }}{% endblock %}
{% block content %}
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body text-center py-4">
<img src="https://minotar.net/avatar/{{ player.username }}/80"
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
{% if player.is_op %}
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
{% endif %}
<table class="table table-sm mt-2 text-start">
<tr><th>UUID</th><td class="small text-break">{{ player.uuid }}</td></tr>
<tr><th>IP</th><td class="small">{{ player.ip_address or '—' }}</td></tr>
<tr><th>Locale</th><td class="small">{{ player.locale or '—' }}</td></tr>
<tr><th>Playtime</th><td>{{ player.total_playtime_sec | fmt_duration }}</td></tr>
<tr><th>Since</th><td class="small">{{ player.first_seen | fmt_dt }}</td></tr>
<tr><th>Last Seen</th><td class="small">{{ player.last_seen | fmt_dt }}</td></tr>
</table>
</div>
</div>
</div>
<div class="col-12 col-md-8">
<ul class="nav nav-tabs mb-3" id="playerTabs">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-sessions">Sessions ({{ sessions|length }})</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-chat">Chat ({{ chat|length }})</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-cmds">Commands ({{ commands|length }})</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deaths">Deaths ({{ deaths|length }})</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-tp">Teleports ({{ teleports|length }})</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-proxy">Proxy ({{ proxy_events|length }})</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-sessions">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Login</th><th>Logout</th><th>Duration</th><th>Server</th><th>IP</th></tr></thead>
<tbody>
{% for s in sessions %}<tr>
<td class="small text-nowrap">{{ s.login_time | fmt_dt }}</td>
<td class="small text-nowrap">{{ s.logout_time | fmt_dt }}</td>
<td class="small">{{ s.duration_sec | fmt_duration }}</td>
<td><span class="badge bg-secondary">{{ s.server_name or '—' }}</span></td>
<td class="small text-muted">{{ s.ip_address or '—' }}</td>
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No sessions</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-chat">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Message</th></tr></thead>
<tbody>
{% for c in chat %}<tr>
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
<td class="small">{{ c.message }}</td>
</tr>{% else %}<tr><td colspan="3" class="text-center text-muted">No chat messages</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-cmds">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Server</th><th>Command</th><th>Position</th></tr></thead>
<tbody>
{% for c in commands %}<tr>
<td class="small text-nowrap text-muted">{{ c.timestamp | fmt_dt }}</td>
<td><span class="badge bg-secondary">{{ c.server_name or '—' }}</span></td>
<td class="small font-monospace">{{ c.command }}</td>
<td class="small text-muted">{{ c.world or '' }} {% if c.x %}({{ c.x|round(1) }}, {{ c.y|round(1) }}, {{ c.z|round(1) }}){% endif %}</td>
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No commands</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-deaths">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Cause</th><th>Killer</th><th>Level</th><th>World</th></tr></thead>
<tbody>
{% for d in deaths %}<tr>
<td class="small text-nowrap text-muted">{{ d.timestamp | fmt_dt }}</td>
<td><span class="badge bg-danger">{{ d.cause or '—' }}</span></td>
<td class="small">{{ d.killer_name or '—' }}</td>
<td class="small">{{ d.exp_level }}</td>
<td class="small text-muted">{{ d.world }}</td>
</tr>{% else %}<tr><td colspan="5" class="text-center text-muted">No deaths</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-tp">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Time</th><th>From</th><th>To</th><th>Cause</th></tr></thead>
<tbody>
{% for t in teleports %}<tr>
<td class="small text-nowrap text-muted">{{ t.timestamp | fmt_dt }}</td>
<td class="small">{{ t.from_world }} ({{ t.from_x|round(0)|int }}, {{ t.from_y|round(0)|int }}, {{ t.from_z|round(0)|int }})</td>
<td class="small">{{ t.to_world }} ({{ t.to_x|round(0)|int }}, {{ t.to_y|round(0)|int }}, {{ t.to_z|round(0)|int }})</td>
<td><span class="badge bg-info text-dark">{{ t.cause or '—' }}</span></td>
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No teleports</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-proxy">
<div class="table-responsive" style="max-height:400px;overflow-y:auto;">
<table class="table table-sm table-hover">
<thead class="table-dark sticky-top"><tr><th>Time</th><th>Type</th><th>From</th><th>To</th></tr></thead>
<tbody>
{% for e in proxy_events %}<tr>
<td class="small text-nowrap text-muted">{{ e.timestamp | fmt_dt }}</td>
<td><span class="badge bg-primary">{{ e.event_type }}</span></td>
<td class="small">{{ e.from_server or '—' }}</td>
<td class="small">{{ e.to_server or '—' }}</td>
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">No proxy events</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Overview
</a>
{% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Players{% endblock %}
{% block page_title %}<i class="bi bi-people-fill me-2"></i>Players{% endblock %}
{% block content %}
<form method="get" class="row g-2 mb-3">
<div class="col-auto flex-grow-1">
<input type="text" name="q" class="form-control" placeholder="Search by player name…" value="{{ search }}">
</div>
<div class="col-auto">
<button class="btn btn-success">Search</button>
{% if search %}<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary ms-1">Reset</a>{% endif %}
</div>
</form>
<div class="card">
<div class="card-header d-flex justify-content-between">
<span>{{ total }} players found</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-dark">
<tr><th>Player</th><th>IP</th><th>First Seen</th><th>Last Seen</th><th>Playtime</th><th>OP</th><th></th></tr>
</thead>
<tbody>
{% for p in players %}
<tr>
<td class="fw-semibold">
<i class="bi bi-person-circle me-1 text-success"></i>{{ p.username }}
</td>
<td class="small text-muted">{{ p.ip_address or '—' }}</td>
<td class="small text-muted text-nowrap">{{ p.first_seen | fmt_dt }}</td>
<td class="small text-muted text-nowrap">{{ p.last_seen | fmt_dt }}</td>
<td class="small">{{ p.total_playtime_sec | fmt_duration }}</td>
<td>
{% if p.is_op %}
<span class="badge bg-warning text-dark"><i class="bi bi-shield-fill"></i> OP</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
<a href="{{ url_for('panel.player_detail', uuid=p.uuid) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No players found</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "_pagination.html" %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More