diff --git a/src/main/java/de/serverpulse/ServerPulse.java b/src/main/java/de/serverpulse/ServerPulse.java new file mode 100644 index 0000000..26d0ad2 --- /dev/null +++ b/src/main/java/de/serverpulse/ServerPulse.java @@ -0,0 +1,194 @@ +package de.serverpulse; + +import de.serverpulse.alerts.AlertManager; +import de.serverpulse.api.RestApiServer; +import de.serverpulse.commands.ServerPulseCommand; +import de.serverpulse.database.DatabaseManager; +import de.serverpulse.discord.DiscordWebhook; +import de.serverpulse.listeners.PlayerListener; +import de.serverpulse.monitoring.EntityMonitor; +import de.serverpulse.monitoring.PerformanceMonitor; +import de.serverpulse.monitoring.TrendAnalyzer; +import de.serverpulse.utils.ConfigManager; +import de.serverpulse.utils.MessageUtil; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.logging.Level; + +/** + * ServerPulse – Monitoring & Analytics Suite + * Hauptklasse des Plugins + */ +public final class ServerPulse extends JavaPlugin { + + private static ServerPulse instance; + + // Manager & Module + private ConfigManager configManager; + private DatabaseManager databaseManager; + private PerformanceMonitor performanceMonitor; + private EntityMonitor entityMonitor; + private TrendAnalyzer trendAnalyzer; + private AlertManager alertManager; + private DiscordWebhook discordWebhook; + private RestApiServer restApiServer; + + @Override + public void onEnable() { + instance = this; + + printBanner(); + + // 1. Konfiguration laden + getLogger().info("Lade Konfiguration..."); + this.configManager = new ConfigManager(this); + configManager.loadAll(); + + // 2. Datenbank initialisieren + getLogger().info("Verbinde mit Datenbank..."); + this.databaseManager = new DatabaseManager(this); + if (!databaseManager.connect()) { + getLogger().severe("Datenbankverbindung fehlgeschlagen! Plugin wird deaktiviert."); + getServer().getPluginManager().disablePlugin(this); + return; + } + databaseManager.createTables(); + + // 3. Discord Webhook initialisieren + this.discordWebhook = new DiscordWebhook(this); + + // 4. Alert Manager initialisieren + this.alertManager = new AlertManager(this); + + // 5. Performance Monitor starten + getLogger().info("Starte Performance Monitor..."); + this.performanceMonitor = new PerformanceMonitor(this); + performanceMonitor.start(); + + // 6. Entity Monitor starten + getLogger().info("Starte Entity Monitor..."); + this.entityMonitor = new EntityMonitor(this); + entityMonitor.start(); + + // 7. Trend Analyzer starten + getLogger().info("Starte Trend Analyzer..."); + this.trendAnalyzer = new TrendAnalyzer(this); + trendAnalyzer.start(); + + // 8. Commands registrieren + getLogger().info("Registriere Befehle..."); + ServerPulseCommand command = new ServerPulseCommand(this); + getCommand("serverpulse").setExecutor(command); + getCommand("serverpulse").setTabCompleter(command); + + // 9. Listener registrieren + getServer().getPluginManager().registerEvents(new PlayerListener(this), this); + + // 10. REST API starten (optional) + if (configManager.isRestApiEnabled()) { + getLogger().info("Starte REST API..."); + this.restApiServer = new RestApiServer(this); + restApiServer.start(); + } + + // 11. Täglichen Report planen + if (configManager.isDailyReportEnabled()) { + discordWebhook.scheduleDailyReport(); + } + + getLogger().info("ServerPulse v" + getDescription().getVersion() + " erfolgreich gestartet!"); + getLogger().info("Überwache " + getServer().getWorlds().size() + " Welt(en)."); + } + + @Override + public void onDisable() { + getLogger().info("ServerPulse wird deaktiviert..."); + + // Alle Monitoring-Tasks stoppen + if (performanceMonitor != null) performanceMonitor.stop(); + if (entityMonitor != null) entityMonitor.stop(); + if (trendAnalyzer != null) trendAnalyzer.stop(); + + // REST API stoppen + if (restApiServer != null) restApiServer.stop(); + + // Datenbankverbindung schließen + if (databaseManager != null) databaseManager.disconnect(); + + getLogger().info("ServerPulse wurde erfolgreich deaktiviert. Auf Wiedersehen!"); + } + + /** + * Konfiguration neu laden + */ + public void reload() { + configManager.loadAll(); + + if (performanceMonitor != null) performanceMonitor.restart(); + if (entityMonitor != null) entityMonitor.restart(); + if (trendAnalyzer != null) trendAnalyzer.restart(); + + getLogger().info("ServerPulse wurde neu geladen."); + } + + // ────────────────────────────────────────── + // GETTER + // ────────────────────────────────────────── + + public static ServerPulse getInstance() { + return instance; + } + + public ConfigManager getConfigManager() { + return configManager; + } + + public DatabaseManager getDatabaseManager() { + return databaseManager; + } + + public PerformanceMonitor getPerformanceMonitor() { + return performanceMonitor; + } + + public EntityMonitor getEntityMonitor() { + return entityMonitor; + } + + public TrendAnalyzer getTrendAnalyzer() { + return trendAnalyzer; + } + + public AlertManager getAlertManager() { + return alertManager; + } + + public DiscordWebhook getDiscordWebhook() { + return discordWebhook; + } + + public RestApiServer getRestApiServer() { + return restApiServer; + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private void printBanner() { + getLogger().info("╔══════════════════════════════════════╗"); + getLogger().info("║ ServerPulse v" + getDescription().getVersion() + " ║"); + getLogger().info("║ Monitoring & Analytics Suite ║"); + getLogger().info("╚══════════════════════════════════════╝"); + } + + public void log(Level level, String message) { + getLogger().log(level, message); + } + + public void debug(String message) { + if (configManager != null && configManager.isDebugMode()) { + getLogger().info("[DEBUG] " + message); + } + } +} diff --git a/src/main/java/de/serverpulse/alerts/AlertManager.java b/src/main/java/de/serverpulse/alerts/AlertManager.java new file mode 100644 index 0000000..c7d44be --- /dev/null +++ b/src/main/java/de/serverpulse/alerts/AlertManager.java @@ -0,0 +1,214 @@ +package de.serverpulse.alerts; + +import de.serverpulse.ServerPulse; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.utils.MessageUtil; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.entity.Monster; + +import java.util.HashMap; +import java.util.Map; + +/** + * Verwaltet alle Warnungen, Cooldowns und Notfallmaßnahmen. + * Verhindert Alert-Spam durch einen Cooldown-Mechanismus. + */ +public class AlertManager { + + private final ServerPulse plugin; + + // Cooldown: alertType -> letzter Trigger-Zeitpunkt (ms) + private final Map alertCooldowns = new HashMap<>(); + // Standard-Cooldown: 5 Minuten + private static final long DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; + + // Zähler für kritische Ereignisse (für Auto-Diagnose) + private int criticalEventCount = 0; + + // Item-Clear läuft gerade? + private boolean itemClearRunning = false; + + public AlertManager(ServerPulse plugin) { + this.plugin = plugin; + } + + // ────────────────────────────────────────── + // ALERT AUSLÖSEN + // ────────────────────────────────────────── + + /** + * Löst eine Warnung aus, wenn kein Cooldown aktiv ist. + */ + public void triggerAlert(String alertType, AlertSeverity severity, String worldName, + String message, Double currentValue, Double threshold) { + // Cooldown prüfen + String cooldownKey = alertType + (worldName != null ? "_" + worldName : ""); + long now = System.currentTimeMillis(); + + if (alertCooldowns.containsKey(cooldownKey)) { + long lastAlert = alertCooldowns.get(cooldownKey); + if (now - lastAlert < DEFAULT_COOLDOWN_MS) { + plugin.debug("Alert-Cooldown aktiv für: " + cooldownKey); + return; + } + } + + alertCooldowns.put(cooldownKey, now); + + // Alert loggen + plugin.getLogger().warning("[" + severity.getName() + "] " + message); + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveAlert( + alertType, severity.getName(), worldName, message, currentValue, threshold + ); + } + + // Discord-Benachrichtigung senden + if (plugin.getConfigManager().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendAlert(alertType, severity, worldName, message, currentValue, threshold); + } + + // Kritische Events zählen für Auto-Diagnose + if (severity == AlertSeverity.CRITICAL) { + criticalEventCount++; + if (plugin.getConfigManager().isAutoDiagnosisEnabled()) { + if (criticalEventCount >= plugin.getConfigManager().getAutoDiagnosisTriggerCount()) { + triggerDiagnosisReport(); + criticalEventCount = 0; + } + } + } + } + + // ────────────────────────────────────────── + // NOTFALLMASSNAHMEN + // ────────────────────────────────────────── + + /** + * Löst einen Item-Clear mit Countdown aus + */ + public void triggerItemClear(String worldName) { + if (itemClearRunning) return; + itemClearRunning = true; + + int countdown = plugin.getConfigManager().getItemClearCountdown(); + String broadcastMsg = plugin.getConfigManager().getItemClearBroadcastMessage(); + + // Countdown-Broadcasts + if (plugin.getConfigManager().isItemClearBroadcast()) { + for (int i = countdown; i > 0; i -= (i > 10 ? 10 : (i > 5 ? 5 : 1))) { + final int secondsLeft = i; + Bukkit.getScheduler().runTaskLater(plugin, () -> { + String msg = broadcastMsg.replace("{seconds}", String.valueOf(secondsLeft)); + Bukkit.broadcastMessage(MessageUtil.colorize(msg)); + }, (countdown - i) * 20L); + } + } + + // Clear ausführen nach Countdown + Bukkit.getScheduler().runTaskLater(plugin, () -> { + World world = Bukkit.getWorld(worldName); + if (world != null) { + int cleared = 0; + for (Entity entity : world.getEntities()) { + if (entity instanceof Item) { + entity.remove(); + cleared++; + } + } + plugin.getLogger().info("Item-Clear ausgeführt in " + worldName + ": " + cleared + " Items entfernt."); + if (plugin.getConfigManager().isItemClearBroadcast()) { + Bukkit.broadcastMessage(MessageUtil.colorize( + "&a[ServerPulse] Item-Clear abgeschlossen! " + cleared + " Items wurden entfernt.")); + } + triggerAlert("ITEM_CLEAR_EXECUTED", AlertSeverity.INFO, worldName, + "Automatischer Item-Clear: " + cleared + " Items in " + worldName + " entfernt.", + (double) cleared, null); + } + itemClearRunning = false; + }, countdown * 20L); + } + + /** + * Führt einen Mob-Clear in einer Welt durch + */ + public void triggerMobClear(String worldName) { + boolean hostileOnly = plugin.getConfigManager().isMobClearHostileOnly(); + World world = Bukkit.getWorld(worldName); + if (world == null) return; + + Bukkit.getScheduler().runTask(plugin, () -> { + int cleared = 0; + for (Entity entity : world.getEntities()) { + if (entity instanceof Monster) { + entity.remove(); + cleared++; + } + } + + plugin.getLogger().info("Mob-Clear ausgeführt in " + worldName + ": " + cleared + " Mobs entfernt."); + triggerAlert("MOB_CLEAR_EXECUTED", AlertSeverity.INFO, worldName, + "Automatischer Mob-Clear: " + cleared + " Mobs in " + worldName + " entfernt.", + (double) cleared, null); + }); + } + + /** + * Erstellt automatisch einen Diagnose-Report + */ + private void triggerDiagnosisReport() { + plugin.getLogger().warning("[ServerPulse] Erstelle automatischen Diagnose-Report..."); + + StringBuilder report = new StringBuilder(); + report.append("=== AUTOMATISCHER DIAGNOSE-REPORT ===\n"); + report.append("Zeitpunkt: ").append(MessageUtil.getTimestamp()).append("\n\n"); + + // Performance + if (plugin.getPerformanceMonitor().getLastSnapshot() != null) { + var snap = plugin.getPerformanceMonitor().getLastSnapshot(); + report.append("Performance:\n"); + report.append(" TPS: ").append(String.format("%.2f", snap.getTps())).append("\n"); + report.append(" MSPT: ").append(String.format("%.2f", snap.getMspt())).append("ms\n"); + report.append(" RAM: ").append(snap.getRamUsedMb()).append("MB / ").append(snap.getRamMaxMb()).append("MB\n"); + report.append(" Spieler: ").append(snap.getOnlinePlayers()).append("\n"); + report.append(" Chunks: ").append(snap.getLoadedChunks()).append("\n\n"); + } + + // Entities pro Welt + report.append("Entities pro Welt:\n"); + for (var entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + var snap = entry.getValue(); + report.append(" ").append(entry.getKey()).append(": ") + .append("Gesamt=").append(snap.getTotal()) + .append(", Monster=").append(snap.getMonsters()) + .append(", Items=").append(snap.getItems()) + .append("\n"); + } + + String reportContent = report.toString(); + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveReport("AUTO_DIAGNOSIS", "ServerPulse", reportContent); + } + + plugin.getLogger().warning(reportContent); + } + + /** + * Setzt alle Alert-Cooldowns zurück (z.B. nach reload) + */ + public void clearCooldowns() { + alertCooldowns.clear(); + criticalEventCount = 0; + } + + public int getCriticalEventCount() { + return criticalEventCount; + } +} diff --git a/src/main/java/de/serverpulse/api/RestApiServer.java b/src/main/java/de/serverpulse/api/RestApiServer.java new file mode 100644 index 0000000..5601d63 --- /dev/null +++ b/src/main/java/de/serverpulse/api/RestApiServer.java @@ -0,0 +1,188 @@ +package de.serverpulse.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import de.serverpulse.ServerPulse; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.logging.Level; + +/** + * Optionaler leichtgewichtiger HTTP-Server für externe Dashboards. + * Stellt REST-Endpunkte bereit (z.B. für Grafana oder eigene Tools). + * + * Endpunkte: + * GET /api/status – Aktueller Server-Status + * GET /api/performance – Performance-Metriken + * GET /api/entities – Entity-Daten pro Welt + * GET /api/health – Einfacher Health-Check + */ +public class RestApiServer { + + private final ServerPulse plugin; + private HttpServer server; + private final Gson gson = new Gson(); + + public RestApiServer(ServerPulse plugin) { + this.plugin = plugin; + } + + public void start() { + try { + int port = plugin.getConfigManager().getRestApiPort(); + server = HttpServer.create(new InetSocketAddress( + plugin.getConfigManager().getRestApiHost(), port), 0); + + // Endpunkte registrieren + server.createContext("/api/health", this::handleHealth); + server.createContext("/api/status", this::handleStatus); + server.createContext("/api/performance", this::handlePerformance); + server.createContext("/api/entities", this::handleEntities); + + server.setExecutor(Executors.newFixedThreadPool(4)); + server.start(); + + plugin.getLogger().info("REST API gestartet auf Port " + port); + plugin.getLogger().info("Endpunkte: /api/health, /api/status, /api/performance, /api/entities"); + + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "REST API konnte nicht gestartet werden: " + e.getMessage(), e); + } + } + + public void stop() { + if (server != null) { + server.stop(0); + plugin.getLogger().info("REST API gestoppt."); + } + } + + // ────────────────────────────────────────── + // ENDPUNKTE + // ────────────────────────────────────────── + + private void handleHealth(HttpExchange exchange) throws IOException { + if (!checkAuth(exchange)) return; + JsonObject response = new JsonObject(); + response.addProperty("status", "ok"); + response.addProperty("plugin", "ServerPulse"); + response.addProperty("version", plugin.getDescription().getVersion()); + sendJson(exchange, 200, response); + } + + private void handleStatus(HttpExchange exchange) throws IOException { + if (!checkAuth(exchange)) return; + + JsonObject response = new JsonObject(); + response.addProperty("server", plugin.getServer().getName()); + response.addProperty("version", plugin.getServer().getVersion()); + response.addProperty("online_players", plugin.getServer().getOnlinePlayers().size()); + response.addProperty("max_players", plugin.getServer().getMaxPlayers()); + response.addProperty("loaded_worlds", plugin.getServer().getWorlds().size()); + + PerformanceSnapshot snap = plugin.getPerformanceMonitor().getLastSnapshot(); + if (snap != null) { + response.addProperty("tps", Math.round(snap.getTps() * 100.0) / 100.0); + response.addProperty("mspt", Math.round(snap.getMspt() * 100.0) / 100.0); + response.addProperty("ram_used_mb", snap.getRamUsedMb()); + response.addProperty("ram_max_mb", snap.getRamMaxMb()); + response.addProperty("ram_percent", Math.round(snap.getRamPercent() * 10.0) / 10.0); + } + + sendJson(exchange, 200, response); + } + + private void handlePerformance(HttpExchange exchange) throws IOException { + if (!checkAuth(exchange)) return; + + PerformanceSnapshot snap = plugin.getPerformanceMonitor().getLastSnapshot(); + if (snap == null) { + JsonObject err = new JsonObject(); + err.addProperty("error", "Noch keine Performance-Daten verfügbar"); + sendJson(exchange, 503, err); + return; + } + + JsonObject response = new JsonObject(); + response.addProperty("tps", snap.getTps()); + response.addProperty("mspt", snap.getMspt()); + response.addProperty("ram_used_mb", snap.getRamUsedMb()); + response.addProperty("ram_max_mb", snap.getRamMaxMb()); + response.addProperty("ram_percent", snap.getRamPercent()); + response.addProperty("online_players", snap.getOnlinePlayers()); + response.addProperty("loaded_worlds", snap.getLoadedWorlds()); + response.addProperty("loaded_chunks", snap.getLoadedChunks()); + response.addProperty("recorded_at", snap.getRecordedAt().toString()); + + sendJson(exchange, 200, response); + } + + private void handleEntities(HttpExchange exchange) throws IOException { + if (!checkAuth(exchange)) return; + + JsonObject response = new JsonObject(); + Map snapshots = plugin.getEntityMonitor().getAllLastSnapshots(); + + for (Map.Entry entry : snapshots.entrySet()) { + EntitySnapshot snap = entry.getValue(); + JsonObject worldData = new JsonObject(); + worldData.addProperty("monsters", snap.getMonsters()); + worldData.addProperty("animals", snap.getAnimals()); + worldData.addProperty("water_mobs", snap.getWaterMobs()); + worldData.addProperty("villagers", snap.getVillagers()); + worldData.addProperty("armor_stands", snap.getArmorStands()); + worldData.addProperty("hopper_minecarts", snap.getHopperMinecarts()); + worldData.addProperty("items", snap.getItems()); + worldData.addProperty("players", snap.getPlayers()); + worldData.addProperty("other", snap.getOther()); + worldData.addProperty("total", snap.getTotal()); + worldData.addProperty("recorded_at", snap.getRecordedAt().toString()); + response.add(entry.getKey(), worldData); + } + + sendJson(exchange, 200, response); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + /** + * Prüft den API-Key im Authorization-Header + */ + private boolean checkAuth(HttpExchange exchange) throws IOException { + String configKey = plugin.getConfigManager().getRestApiKey(); + if (configKey == null || configKey.isEmpty() || configKey.equals("YOUR_SECURE_API_KEY")) { + return true; // Kein Key konfiguriert = kein Auth + } + + String authHeader = exchange.getRequestHeaders().getFirst("Authorization"); + if (authHeader == null || !authHeader.equals("Bearer " + configKey)) { + JsonObject error = new JsonObject(); + error.addProperty("error", "Unauthorized"); + sendJson(exchange, 401, error); + return false; + } + return true; + } + + private void sendJson(HttpExchange exchange, int statusCode, JsonObject body) throws IOException { + byte[] responseBytes = gson.toJson(body).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.sendResponseHeaders(statusCode, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + } +} diff --git a/src/main/java/de/serverpulse/bungee/BungeePlugin.java b/src/main/java/de/serverpulse/bungee/BungeePlugin.java new file mode 100644 index 0000000..a8e4816 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/BungeePlugin.java @@ -0,0 +1,105 @@ +package de.serverpulse.bungee; + +import de.serverpulse.bungee.api.GrafanaApiServer; +import de.serverpulse.bungee.api.InfluxPushService; +import de.serverpulse.bungee.commands.BungeeCommand; +import de.serverpulse.bungee.discord.BungeeDiscordWebhook; +import de.serverpulse.bungee.listeners.BungeePlayerListener; +import de.serverpulse.bungee.network.BungeeAlertManager; +import de.serverpulse.bungee.network.NetworkDataStore; +import de.serverpulse.bungee.network.PluginMessageHandler; +import de.serverpulse.bungee.utils.BungeeConfig; +import de.serverpulse.network.NetworkMessage; +import net.md_5.bungee.api.plugin.Plugin; + +/** + * BungeeCord-Einstiegspunkt der unified JAR. + * Wird von bungee.yml geladen wenn das Plugin auf einem BungeeCord-Proxy läuft. + * + * Beide Klassen (SpigotPlugin + BungeePlugin) sind in derselben JAR – + * Minecraft/BungeeCord lädt automatisch nur die jeweils passende Klasse + * anhand der plugin.yml (Spigot) bzw. bungee.yml (BungeeCord). + */ +public final class BungeePlugin extends Plugin { + + private static BungeePlugin instance; + + private BungeeConfig config; + private NetworkDataStore dataStore; + private BungeeAlertManager alertManager; + private BungeeDiscordWebhook discordWebhook; + private GrafanaApiServer grafanaApiServer; + private InfluxPushService influxPushService; + + @Override + public void onEnable() { + instance = this; + printBanner(); + + // 1. Config + this.config = new BungeeConfig(this); + config.load(); + + // 2. Datenspeicher + this.dataStore = new NetworkDataStore(); + + // 3. Alert Manager + this.alertManager = new BungeeAlertManager(this); + + // 4. Discord + this.discordWebhook = new BungeeDiscordWebhook(this); + if (config.isDailyReportEnabled()) { + discordWebhook.scheduleDailyReport(); + } + + // 5. Plugin Messaging Channel (Daten von Sub-Servern empfangen) + getProxy().registerChannel(NetworkMessage.CHANNEL); + getProxy().getPluginManager().registerListener(this, new PluginMessageHandler(this)); + + // 6. Spieler-Listener + getProxy().getPluginManager().registerListener(this, new BungeePlayerListener(this)); + + // 7. Befehl + getProxy().getPluginManager().registerCommand(this, new BungeeCommand(this)); + + // 8. REST API / Grafana + if (config.isRestApiEnabled()) { + this.grafanaApiServer = new GrafanaApiServer(this); + grafanaApiServer.start(); + + if (config.isInfluxPushEnabled()) { + this.influxPushService = new InfluxPushService(this); + influxPushService.start(); + } + } + + getLogger().info("ServerPulse v" + getDescription().getVersion() + + " (BungeeCord) erfolgreich gestartet!"); + getLogger().info("Channel: " + NetworkMessage.CHANNEL); + } + + @Override + public void onDisable() { + if (grafanaApiServer != null) grafanaApiServer.stop(); + if (influxPushService != null) influxPushService.stop(); + getProxy().unregisterChannel(NetworkMessage.CHANNEL); + getLogger().info("ServerPulse (BungeeCord) deaktiviert."); + } + + private void printBanner() { + getLogger().info("╔══════════════════════════════════════╗"); + getLogger().info("║ ServerPulse v1.2.0 [BUNGEECORD] ║"); + getLogger().info("╚══════════════════════════════════════╝"); + } + + public void debug(String msg) { + if (config != null && config.isDebugMode()) getLogger().info("[DEBUG] " + msg); + } + + public static BungeePlugin getInstance() { return instance; } + public BungeeConfig getBungeeConfig() { return config; } + public NetworkDataStore getDataStore() { return dataStore; } + public BungeeAlertManager getAlertManager() { return alertManager; } + public BungeeDiscordWebhook getDiscordWebhook(){ return discordWebhook; } + public GrafanaApiServer getGrafanaApiServer() { return grafanaApiServer; } +} diff --git a/src/main/java/de/serverpulse/bungee/api/GrafanaApiServer.java b/src/main/java/de/serverpulse/bungee/api/GrafanaApiServer.java new file mode 100644 index 0000000..b83b5b8 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/api/GrafanaApiServer.java @@ -0,0 +1,454 @@ +package de.serverpulse.bungee.api; + +import com.google.gson.*; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; + +import java.io.*; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.logging.Level; + +/** + * Multi-Format REST API für Grafana-Integration. + * + * Unterstützt (je nach Config): + * → /metrics Prometheus Text Format + * → /grafana Grafana SimpleJSON Plugin + * → /influx/metrics InfluxDB Line Protocol + * → /api/... Standard JSON (network, servers, performance) + * + * Alle Endpunkte können unabhängig in der config.yml aktiviert werden. + */ +public class GrafanaApiServer { + + private final BungeePlugin plugin; + private HttpServer server; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + public GrafanaApiServer(BungeePlugin plugin) { + this.plugin = plugin; + } + + public void start() { + try { + int port = plugin.getBungeeConfig().getRestApiPort(); + server = HttpServer.create( + new InetSocketAddress(plugin.getBungeeConfig().getRestApiHost(), port), 0); + + registerEndpoints(); + + server.setExecutor(Executors.newFixedThreadPool(4)); + server.start(); + + plugin.getLogger().info("REST API gestartet auf Port " + port); + logActiveEndpoints(); + + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "REST API konnte nicht gestartet werden: " + e.getMessage(), e); + } + } + + public void stop() { + if (server != null) { + server.stop(0); + plugin.getLogger().info("REST API gestoppt."); + } + } + + // ────────────────────────────────────────── + // ENDPUNKTE REGISTRIEREN + // ────────────────────────────────────────── + + private void registerEndpoints() { + // Immer: Health-Check + server.createContext("/health", this::handleHealth); + + // Prometheus + if (plugin.getBungeeConfig().isPrometheusEnabled()) { + String ep = plugin.getBungeeConfig().getPrometheusEndpoint(); + server.createContext(ep, this::handlePrometheus); + plugin.getLogger().info(" → Prometheus: " + ep); + } + + // Grafana SimpleJSON + if (plugin.getBungeeConfig().isSimpleJsonEnabled()) { + String ep = plugin.getBungeeConfig().getSimpleJsonEndpoint(); + server.createContext(ep, this::handleSimpleJson); + server.createContext(ep + "/search", this::handleSimpleJsonSearch); + server.createContext(ep + "/query", this::handleSimpleJsonQuery); + plugin.getLogger().info(" → SimpleJSON: " + ep); + } + + // InfluxDB Line Protocol + if (plugin.getBungeeConfig().isInfluxEnabled()) { + String ep = plugin.getBungeeConfig().getInfluxEndpoint(); + server.createContext(ep + "/metrics", this::handleInflux); + plugin.getLogger().info(" → InfluxDB: " + ep + "/metrics"); + } + + // Standard JSON API + if (plugin.getBungeeConfig().isJsonApiEnabled()) { + String ep = plugin.getBungeeConfig().getJsonApiEndpoint(); + server.createContext(ep + "/network", this::handleJsonNetwork); + server.createContext(ep + "/servers", this::handleJsonServers); + server.createContext(ep + "/performance", this::handleJsonPerformance); + server.createContext(ep + "/entities", this::handleJsonEntities); + plugin.getLogger().info(" → JSON API: " + ep + "/{network,servers,performance,entities}"); + } + } + + private void logActiveEndpoints() { + int port = plugin.getBungeeConfig().getRestApiPort(); + plugin.getLogger().info("Grafana-Datasource URLs:"); + if (plugin.getBungeeConfig().isPrometheusEnabled()) + plugin.getLogger().info(" Prometheus: http://HOST:" + port + + plugin.getBungeeConfig().getPrometheusEndpoint()); + if (plugin.getBungeeConfig().isSimpleJsonEnabled()) + plugin.getLogger().info(" SimpleJSON: http://HOST:" + port + + plugin.getBungeeConfig().getSimpleJsonEndpoint()); + if (plugin.getBungeeConfig().isInfluxEnabled()) + plugin.getLogger().info(" InfluxDB: http://HOST:" + port + + plugin.getBungeeConfig().getInfluxEndpoint() + "/metrics"); + } + + // ────────────────────────────────────────── + // HEALTH CHECK + // ────────────────────────────────────────── + + private void handleHealth(HttpExchange ex) throws IOException { + JsonObject r = new JsonObject(); + r.addProperty("status", "ok"); + r.addProperty("plugin", "BungeePlugin"); + r.addProperty("version", plugin.getDescription().getVersion()); + r.addProperty("online_servers", plugin.getDataStore().getOnlineServerCount()); + r.addProperty("total_players", plugin.getDataStore().getTotalOnlinePlayers()); + sendResponse(ex, 200, "application/json", gson.toJson(r)); + } + + // ────────────────────────────────────────── + // PROMETHEUS FORMAT + // ────────────────────────────────────────── + + /** + * Prometheus Text Exposition Format 0.0.4 + * Jeder Metric-Eintrag: metric_name{label="value"} value timestamp + */ + private void handlePrometheus(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + + StringBuilder sb = new StringBuilder(); + + // ── HELP & TYPE Definitionen ────────── + appendPrometheusHelp(sb, "serverpulse_tps", "gauge", "Current TPS of the sub-server"); + appendPrometheusHelp(sb, "serverpulse_mspt", "gauge", "Current MSPT of the sub-server in ms"); + appendPrometheusHelp(sb, "serverpulse_ram_used_mb", "gauge", "RAM used in megabytes"); + appendPrometheusHelp(sb, "serverpulse_ram_max_mb", "gauge", "Max RAM in megabytes"); + appendPrometheusHelp(sb, "serverpulse_ram_percent", "gauge", "RAM usage in percent"); + appendPrometheusHelp(sb, "serverpulse_online_players", "gauge", "Online players on this sub-server"); + appendPrometheusHelp(sb, "serverpulse_loaded_chunks", "gauge", "Loaded chunks"); + appendPrometheusHelp(sb, "serverpulse_entities_total", "gauge", "Total entity count across all worlds"); + appendPrometheusHelp(sb, "serverpulse_network_players_total", "gauge", "Total players across all servers"); + appendPrometheusHelp(sb, "serverpulse_network_tps_avg", "gauge", "Average TPS across all servers"); + appendPrometheusHelp(sb, "serverpulse_network_tps_min", "gauge", "Lowest TPS across all servers"); + + long timestamp = Instant.now().toEpochMilli(); + + // ── Netzwerk-Metriken ───────────────── + sb.append(String.format("serverpulse_network_players_total %d %d%n", + plugin.getDataStore().getTotalOnlinePlayers(), timestamp)); + sb.append(String.format("serverpulse_network_tps_avg %.4f %d%n", + plugin.getDataStore().getNetworkAverageTps(), timestamp)); + sb.append(String.format("serverpulse_network_tps_min %.4f %d%n", + plugin.getDataStore().getLowestTps(), timestamp)); + + // ── Pro-Server-Metriken ─────────────── + for (ServerData data : plugin.getDataStore().getAll()) { + if (!data.isOnline()) continue; + String label = "server=\"" + data.getServerName() + "\""; + sb.append(String.format("serverpulse_tps{%s} %.4f %d%n", label, data.getTps(), timestamp)); + sb.append(String.format("serverpulse_mspt{%s} %.4f %d%n", label, data.getMspt(), timestamp)); + sb.append(String.format("serverpulse_ram_used_mb{%s} %d %d%n", label, data.getRamUsedMb(), timestamp)); + sb.append(String.format("serverpulse_ram_max_mb{%s} %d %d%n", label, data.getRamMaxMb(), timestamp)); + sb.append(String.format("serverpulse_ram_percent{%s} %.2f %d%n", label, data.getRamPercent(), timestamp)); + sb.append(String.format("serverpulse_online_players{%s} %d %d%n", label, data.getOnlinePlayers(), timestamp)); + sb.append(String.format("serverpulse_loaded_chunks{%s} %d %d%n", label, data.getLoadedChunks(), timestamp)); + sb.append(String.format("serverpulse_entities_total{%s} %d %d%n", label, data.getTotalEntities(), timestamp)); + + // Entity-Typen pro Welt + for (Map.Entry> worldEntry : data.getEntityData().entrySet()) { + String worldLabel = "server=\"" + data.getServerName() + "\",world=\"" + worldEntry.getKey() + "\""; + for (Map.Entry entityEntry : worldEntry.getValue().entrySet()) { + sb.append(String.format("serverpulse_entities_%s{%s} %d %d%n", + entityEntry.getKey(), worldLabel, entityEntry.getValue(), timestamp)); + } + } + } + + sendResponse(ex, 200, "text/plain; version=0.0.4; charset=utf-8", sb.toString()); + } + + private void appendPrometheusHelp(StringBuilder sb, String name, String type, String help) { + sb.append("# HELP ").append(name).append(" ").append(help).append("\n"); + sb.append("# TYPE ").append(name).append(" ").append(type).append("\n"); + } + + // ────────────────────────────────────────── + // GRAFANA SIMPLE JSON FORMAT + // ────────────────────────────────────────── + + /** Root: Grafana testet ob der Endpoint erreichbar ist */ + private void handleSimpleJson(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + sendResponse(ex, 200, "application/json", "\"ServerPulse SimpleJSON API ready\""); + } + + /** /search → Liste aller verfügbaren Metriken */ + private void handleSimpleJsonSearch(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + JsonArray targets = new JsonArray(); + // Netzwerk + targets.add("network.players_total"); + targets.add("network.tps_avg"); + targets.add("network.tps_min"); + targets.add("network.ram_used_mb"); + // Pro Server (dynamisch) + for (ServerData data : plugin.getDataStore().getAll()) { + String s = data.getServerName(); + targets.add(s + ".tps"); + targets.add(s + ".mspt"); + targets.add(s + ".ram_percent"); + targets.add(s + ".online_players"); + targets.add(s + ".entities_total"); + } + sendResponse(ex, 200, "application/json", gson.toJson(targets)); + } + + /** /query → Zeitreihendaten für angefragte Targets */ + private void handleSimpleJsonQuery(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + if (!ex.getRequestMethod().equalsIgnoreCase("POST")) { + sendResponse(ex, 405, "application/json", "{\"error\":\"POST required\"}"); + return; + } + + String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + JsonObject req = gson.fromJson(body, JsonObject.class); + JsonArray reqTargets = req.getAsJsonArray("targets"); + + JsonArray response = new JsonArray(); + long now = Instant.now().toEpochMilli(); + + for (JsonElement targetEl : reqTargets) { + String target = targetEl.getAsJsonObject().get("target").getAsString(); + double value = resolveTarget(target); + + JsonObject series = new JsonObject(); + series.addProperty("target", target); + JsonArray datapoints = new JsonArray(); + JsonArray point = new JsonArray(); + point.add(value); + point.add(now); + datapoints.add(point); + series.add("datapoints", datapoints); + response.add(series); + } + + sendResponse(ex, 200, "application/json", gson.toJson(response)); + } + + private double resolveTarget(String target) { + // Netzwerk-Targets + return switch (target) { + case "network.players_total" -> plugin.getDataStore().getTotalOnlinePlayers(); + case "network.tps_avg" -> plugin.getDataStore().getNetworkAverageTps(); + case "network.tps_min" -> plugin.getDataStore().getLowestTps(); + case "network.ram_used_mb" -> plugin.getDataStore().getTotalRamUsedMb(); + default -> { + // Format: "serverName.metric" + String[] parts = target.split("\\.", 2); + if (parts.length == 2) { + ServerData data = plugin.getDataStore().get(parts[0]); + if (data != null) { + yield switch (parts[1]) { + case "tps" -> data.getTps(); + case "mspt" -> data.getMspt(); + case "ram_percent" -> data.getRamPercent(); + case "ram_used_mb" -> data.getRamUsedMb(); + case "online_players" -> data.getOnlinePlayers(); + case "entities_total" -> data.getTotalEntities(); + case "loaded_chunks" -> data.getLoadedChunks(); + default -> 0.0; + }; + } + } + yield 0.0; + } + }; + } + + // ────────────────────────────────────────── + // INFLUXDB LINE PROTOCOL + // ────────────────────────────────────────── + + /** + * InfluxDB Line Protocol Format: + * measurement,tag_key=tag_value field_key=field_value timestamp_ns + */ + private void handleInflux(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + + StringBuilder sb = new StringBuilder(); + String measurement = plugin.getBungeeConfig().getInfluxMeasurement(); + long timestampNs = Instant.now().toEpochMilli() * 1_000_000L; + + // Netzwerk-Metriken + sb.append(String.format("%s,source=network ",measurement)); + sb.append(String.format("players_total=%di,", plugin.getDataStore().getTotalOnlinePlayers())); + sb.append(String.format("tps_avg=%.4f,", plugin.getDataStore().getNetworkAverageTps())); + sb.append(String.format("tps_min=%.4f,", plugin.getDataStore().getLowestTps())); + sb.append(String.format("ram_used_mb=%di,", plugin.getDataStore().getTotalRamUsedMb())); + sb.append(String.format("online_servers=%di", plugin.getDataStore().getOnlineServerCount())); + sb.append(" ").append(timestampNs).append("\n"); + + // Pro-Server-Metriken + for (ServerData data : plugin.getDataStore().getAll()) { + if (!data.isOnline()) continue; + // Leerzeichen in Server-Namen escapen (InfluxDB-Regel) + String serverTag = data.getServerName().replace(" ", "\\ "); + + sb.append(String.format("%s,source=server,server=%s ", measurement, serverTag)); + sb.append(String.format("tps=%.4f,", data.getTps())); + sb.append(String.format("mspt=%.4f,", data.getMspt())); + sb.append(String.format("ram_used_mb=%di,", data.getRamUsedMb())); + sb.append(String.format("ram_max_mb=%di,", data.getRamMaxMb())); + sb.append(String.format("ram_percent=%.2f,", data.getRamPercent())); + sb.append(String.format("online_players=%di,", data.getOnlinePlayers())); + sb.append(String.format("loaded_chunks=%di,", data.getLoadedChunks())); + sb.append(String.format("entities_total=%di", data.getTotalEntities())); + sb.append(" ").append(timestampNs).append("\n"); + + // Entity-Daten pro Welt + for (Map.Entry> worldEntry : data.getEntityData().entrySet()) { + String worldTag = worldEntry.getKey().replace(" ", "\\ "); + sb.append(String.format("%s,source=entities,server=%s,world=%s ", + measurement, serverTag, worldTag)); + Map entities = worldEntry.getValue(); + List fields = new ArrayList<>(); + entities.forEach((k, v) -> fields.add(k + "=" + v + "i")); + sb.append(String.join(",", fields)); + sb.append(" ").append(timestampNs).append("\n"); + } + } + + sendResponse(ex, 200, "text/plain; charset=utf-8", sb.toString()); + } + + // ────────────────────────────────────────── + // STANDARD JSON API + // ────────────────────────────────────────── + + private void handleJsonNetwork(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + JsonObject r = new JsonObject(); + r.addProperty("total_players", plugin.getDataStore().getTotalOnlinePlayers()); + r.addProperty("online_servers", plugin.getDataStore().getOnlineServerCount()); + r.addProperty("total_servers", plugin.getDataStore().getServerCount()); + r.addProperty("network_tps_avg", plugin.getDataStore().getNetworkAverageTps()); + r.addProperty("network_tps_min", plugin.getDataStore().getLowestTps()); + r.addProperty("ram_used_mb", plugin.getDataStore().getTotalRamUsedMb()); + r.addProperty("ram_max_mb", plugin.getDataStore().getTotalRamMaxMb()); + sendResponse(ex, 200, "application/json", gson.toJson(r)); + } + + private void handleJsonServers(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + JsonArray arr = new JsonArray(); + for (ServerData data : plugin.getDataStore().getAll()) { + JsonObject s = new JsonObject(); + s.addProperty("name", data.getServerName()); + s.addProperty("online", data.isOnline()); + s.addProperty("tps", data.getTps()); + s.addProperty("mspt", data.getMspt()); + s.addProperty("ram_used_mb", data.getRamUsedMb()); + s.addProperty("ram_max_mb", data.getRamMaxMb()); + s.addProperty("ram_percent", data.getRamPercent()); + s.addProperty("online_players", data.getOnlinePlayers()); + s.addProperty("loaded_chunks", data.getLoadedChunks()); + s.addProperty("entities_total", data.getTotalEntities()); + if (data.getLastUpdate() != null) + s.addProperty("last_update", data.getLastUpdate().toString()); + arr.add(s); + } + sendResponse(ex, 200, "application/json", gson.toJson(arr)); + } + + private void handleJsonPerformance(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + JsonObject r = new JsonObject(); + for (ServerData data : plugin.getDataStore().getAll()) { + if (!data.isOnline()) continue; + JsonObject perf = new JsonObject(); + perf.addProperty("tps", data.getTps()); + perf.addProperty("mspt", data.getMspt()); + perf.addProperty("ram_used_mb", data.getRamUsedMb()); + perf.addProperty("ram_max_mb", data.getRamMaxMb()); + perf.addProperty("ram_percent", data.getRamPercent()); + perf.addProperty("online_players", data.getOnlinePlayers()); + r.add(data.getServerName(), perf); + } + sendResponse(ex, 200, "application/json", gson.toJson(r)); + } + + private void handleJsonEntities(HttpExchange ex) throws IOException { + if (!checkAuth(ex)) return; + JsonObject r = new JsonObject(); + for (ServerData data : plugin.getDataStore().getAll()) { + if (!data.isOnline()) continue; + JsonObject serverObj = new JsonObject(); + for (Map.Entry> world : data.getEntityData().entrySet()) { + JsonObject worldObj = new JsonObject(); + world.getValue().forEach(worldObj::addProperty); + serverObj.add(world.getKey(), worldObj); + } + r.add(data.getServerName(), serverObj); + } + sendResponse(ex, 200, "application/json", gson.toJson(r)); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private boolean checkAuth(HttpExchange ex) throws IOException { + String key = plugin.getBungeeConfig().getRestApiKey(); + if (key == null || key.isEmpty() || key.equals("YOUR_SECURE_API_KEY")) return true; + String auth = ex.getRequestHeaders().getFirst("Authorization"); + if (auth == null || !auth.equals("Bearer " + key)) { + sendResponse(ex, 401, "application/json", "{\"error\":\"Unauthorized\"}"); + return false; + } + return true; + } + + private void sendResponse(HttpExchange ex, int code, String contentType, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", contentType); + ex.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + ex.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + ex.getResponseHeaders().set("Access-Control-Allow-Headers", "Authorization, Content-Type"); + if (ex.getRequestMethod().equalsIgnoreCase("OPTIONS")) { + ex.sendResponseHeaders(204, -1); + return; + } + ex.sendResponseHeaders(code, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } +} diff --git a/src/main/java/de/serverpulse/bungee/api/InfluxPushService.java b/src/main/java/de/serverpulse/bungee/api/InfluxPushService.java new file mode 100644 index 0000000..69ea81a --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/api/InfluxPushService.java @@ -0,0 +1,106 @@ +package de.serverpulse.bungee.api; + +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; +import okhttp3.*; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * Optionaler Service der Metriken aktiv an eine InfluxDB v2 Instanz pusht. + * Aktivierbar über rest-api.formats.influxdb.push.enabled=true in der config. + */ +public class InfluxPushService { + + private final BungeePlugin plugin; + private final OkHttpClient httpClient; + private net.md_5.bungee.api.scheduler.ScheduledTask task; + + public InfluxPushService(BungeePlugin plugin) { + this.plugin = plugin; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(); + } + + public void start() { + int interval = plugin.getBungeeConfig().getPollInterval(); + task = plugin.getProxy().getScheduler().schedule(plugin, this::push, + interval, interval, TimeUnit.SECONDS); + plugin.getLogger().info("InfluxDB Push-Service gestartet (Intervall: " + interval + "s)"); + } + + public void stop() { + if (task != null) task.cancel(); + } + + private void push() { + String body = buildLineProtocol(); + if (body.isEmpty()) return; + + String url = plugin.getBungeeConfig().getInfluxPushUrl() + + "/api/v2/write?org=" + plugin.getBungeeConfig().getInfluxPushOrg() + + "&bucket=" + plugin.getBungeeConfig().getInfluxPushBucket() + + "&precision=ns"; + String token = plugin.getBungeeConfig().getInfluxPushToken(); + + RequestBody requestBody = RequestBody.create(body, MediaType.get("text/plain; charset=utf-8")); + Request request = new Request.Builder() + .url(url) + .addHeader("Authorization", "Token " + token) + .post(requestBody) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + plugin.getLogger().warning("InfluxDB Push fehlgeschlagen: HTTP " + response.code()); + } else { + plugin.debug("InfluxDB Push erfolgreich (" + body.lines().count() + " Zeilen)"); + } + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "InfluxDB Push Verbindungsfehler: " + e.getMessage()); + } + } + + private String buildLineProtocol() { + StringBuilder sb = new StringBuilder(); + String measurement = plugin.getBungeeConfig().getInfluxMeasurement(); + long ts = Instant.now().toEpochMilli() * 1_000_000L; + + for (ServerData data : plugin.getDataStore().getAll()) { + if (!data.isOnline()) continue; + String serverTag = data.getServerName().replace(" ", "\\ "); + + sb.append(measurement) + .append(",source=server,server=").append(serverTag).append(" ") + .append(String.format("tps=%.4f,mspt=%.4f,ram_used_mb=%di,ram_max_mb=%di," + + "ram_percent=%.2f,online_players=%di,loaded_chunks=%di,entities_total=%di", + data.getTps(), data.getMspt(), data.getRamUsedMb(), data.getRamMaxMb(), + data.getRamPercent(), data.getOnlinePlayers(), + data.getLoadedChunks(), data.getTotalEntities())) + .append(" ").append(ts).append("\n"); + } + + // Netzwerk-Gesamt + sb.append(measurement) + .append(",source=network ") + .append(String.format("players_total=%di,tps_avg=%.4f,tps_min=%.4f," + + "ram_used_mb=%di,online_servers=%di", + plugin.getDataStore().getTotalOnlinePlayers(), + plugin.getDataStore().getNetworkAverageTps(), + plugin.getDataStore().getLowestTps(), + plugin.getDataStore().getTotalRamUsedMb(), + plugin.getDataStore().getOnlineServerCount())) + .append(" ").append(ts).append("\n"); + + return sb.toString(); + } +} diff --git a/src/main/java/de/serverpulse/bungee/commands/BungeeCommand.java b/src/main/java/de/serverpulse/bungee/commands/BungeeCommand.java new file mode 100644 index 0000000..502f346 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/commands/BungeeCommand.java @@ -0,0 +1,179 @@ +package de.serverpulse.bungee.commands; + +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Command; + +/** + * /bpulse Befehlsverarbeitung für BungeeCord. + * Unterbefehle: status | network | servers | reload + */ +public class BungeeCommand extends Command { + + private final BungeePlugin plugin; + + public BungeeCommand(BungeePlugin plugin) { + super("bpulse", "serverpulse.bungee.use", "bsp", "networkpulse"); + this.plugin = plugin; + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { + sendHelp(sender); + return; + } + switch (args[0].toLowerCase()) { + case "status" -> handleStatus(sender); + case "network" -> handleNetwork(sender); + case "servers" -> handleServers(sender); + case "reload" -> handleReload(sender); + case "report" -> handleReport(sender); + default -> sendHelp(sender); + } + } + + // ────────────────────────────────────────── + // STATUS + // ────────────────────────────────────────── + + private void handleStatus(CommandSender sender) { + send(sender, ""); + send(sender, "§8§m──────────────────────────────────────────"); + send(sender, " §b§lServerPulse §8– §7Netzwerk-Status"); + send(sender, "§8§m──────────────────────────────────────────"); + send(sender, " §7Spieler gesamt: §f" + plugin.getDataStore().getTotalOnlinePlayers()); + send(sender, " §7Server online: §f" + + plugin.getDataStore().getOnlineServerCount() + + " §8/ §f" + plugin.getDataStore().getServerCount()); + send(sender, " §7Ø TPS (Netz): " + formatTps(plugin.getDataStore().getNetworkAverageTps())); + send(sender, " §7Min TPS: " + formatTps(plugin.getDataStore().getLowestTps())); + send(sender, " §7RAM gesamt: §f" + + plugin.getDataStore().getTotalRamUsedMb() + " MB" + + " §8/ §f" + plugin.getDataStore().getTotalRamMaxMb() + " MB"); + send(sender, "§8§m──────────────────────────────────────────"); + } + + // ────────────────────────────────────────── + // NETWORK + // ────────────────────────────────────────── + + private void handleNetwork(CommandSender sender) { + send(sender, ""); + send(sender, "§b§lServerPulse §8– §7Netzwerk-Übersicht"); + send(sender, "§8§m──────────────────────────────────────────"); + + for (DataEntry entry : java.util.List.of( + new DataEntry("Online Spieler", + String.valueOf(plugin.getDataStore().getTotalOnlinePlayers())), + new DataEntry("Aktive Server", + plugin.getDataStore().getOnlineServerCount() + " / " + plugin.getDataStore().getServerCount()), + new DataEntry("Ø Netz-TPS", + String.format("%.2f", plugin.getDataStore().getNetworkAverageTps())), + new DataEntry("Niedrigste TPS", + String.format("%.2f", plugin.getDataStore().getLowestTps())), + new DataEntry("RAM gesamt", + plugin.getDataStore().getTotalRamUsedMb() + " MB / " + + plugin.getDataStore().getTotalRamMaxMb() + " MB"))) { + send(sender, " §7" + entry.label + ": §f" + entry.value); + } + send(sender, "§8§m──────────────────────────────────────────"); + } + + // ────────────────────────────────────────── + // SERVERS + // ────────────────────────────────────────── + + private void handleServers(CommandSender sender) { + send(sender, ""); + send(sender, "§b§lServerPulse §8– §7Server-Details"); + send(sender, "§8§m──────────────────────────────────────────"); + + for (ServerData data : plugin.getDataStore().getAll()) { + String status = data.isOnline() ? "§a●" : "§c●"; + if (data.isOnline()) { + send(sender, " " + status + " §f§l" + data.getServerName()); + send(sender, " §7TPS: " + formatTps(data.getTps()) + + " §7MSPT: §f" + String.format("%.1f", data.getMspt()) + "ms"); + send(sender, " §7RAM: §f" + data.getRamUsedMb() + "MB §8/ §f" + data.getRamMaxMb() + "MB" + + " §7Spieler: §f" + data.getOnlinePlayers()); + send(sender, " §7Entities: §f" + data.getTotalEntities() + + " §7Chunks: §f" + data.getLoadedChunks()); + if (!data.getLastAlert().isEmpty()) { + String color = data.getLastAlertSeverity().equals("CRITICAL") ? "§c" : "§e"; + send(sender, " §7Letzter Alert: " + color + data.getLastAlert()); + } + } else { + send(sender, " " + status + " §7" + data.getServerName() + " §8(Offline)"); + } + } + + if (plugin.getDataStore().getAll().isEmpty()) { + send(sender, " §7Noch keine Server-Daten empfangen."); + } + send(sender, "§8§m──────────────────────────────────────────"); + } + + // ────────────────────────────────────────── + // REPORT + // ────────────────────────────────────────── + + private void handleReport(CommandSender sender) { + if (!plugin.getBungeeConfig().isDiscordEnabled()) { + send(sender, "§cDiscord ist nicht aktiviert. Report kann nicht gesendet werden."); + return; + } + send(sender, "§aSende täglichen Netzwerk-Report an Discord..."); + plugin.getProxy().getScheduler().runAsync(plugin, + () -> plugin.getDiscordWebhook().sendDailyReport()); + send(sender, "§aReport wurde gesendet!"); + } + + // ────────────────────────────────────────── + // RELOAD + // ────────────────────────────────────────── + + private void handleReload(CommandSender sender) { + if (!sender.hasPermission("serverpulse.bungee.admin")) { + send(sender, "§cKeine Berechtigung."); + return; + } + plugin.getBungeeConfig().load(); + send(sender, "§aKonfiguration wurde neu geladen."); + } + + // ────────────────────────────────────────── + // HILFE + // ────────────────────────────────────────── + + private void sendHelp(CommandSender sender) { + send(sender, "§8§m──────────────────────────────────────────"); + send(sender, " §b§lServerPulse BungeeCord §8– §7Befehle"); + send(sender, "§8§m──────────────────────────────────────────"); + send(sender, " §b/bpulse status §8– §7Netzwerk-Schnellübersicht"); + send(sender, " §b/bpulse network §8– §7Netzwerk-Statistiken"); + send(sender, " §b/bpulse servers §8– §7Details aller Sub-Server"); + send(sender, " §b/bpulse report §8– §7Discord-Report senden"); + send(sender, " §b/bpulse reload §8– §7Konfiguration neu laden"); + send(sender, "§8§m──────────────────────────────────────────"); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private void send(CommandSender sender, String message) { + sender.sendMessage(new TextComponent(ChatColor.translateAlternateColorCodes('§', message))); + } + + private String formatTps(double tps) { + if (tps >= 18.0) return "§a" + String.format("%.2f", tps); + if (tps >= 15.0) return "§e" + String.format("%.2f", tps); + return "§c" + String.format("%.2f", tps); + } + + private record DataEntry(String label, String value) {} +} diff --git a/src/main/java/de/serverpulse/bungee/discord/BungeeDiscordWebhook.java b/src/main/java/de/serverpulse/bungee/discord/BungeeDiscordWebhook.java new file mode 100644 index 0000000..8a513fd --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/discord/BungeeDiscordWebhook.java @@ -0,0 +1,205 @@ +package de.serverpulse.bungee.discord; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; +import okhttp3.*; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * Discord-Webhook-Integration für das BungeeCord-Plugin. + * Unterstützt Server-Alerts, Chat-Warnungen und den täglichen Netzwerk-Report. + */ +public class BungeeDiscordWebhook { + + private final BungeePlugin plugin; + private final OkHttpClient httpClient; + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"); + + public BungeeDiscordWebhook(BungeePlugin plugin) { + this.plugin = plugin; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + // ────────────────────────────────────────── + // SERVER ALERT + // ────────────────────────────────────────── + + public void sendServerAlert(String serverName, String alertType, String severity, String message) { + int color = switch (severity) { + case "CRITICAL" -> plugin.getBungeeConfig().getColorCritical(); + case "WARNING" -> plugin.getBungeeConfig().getColorWarning(); + default -> plugin.getBungeeConfig().getColorInfo(); + }; + String emoji = severity.equals("CRITICAL") ? "🔴" : (severity.equals("WARNING") ? "🟡" : "🔵"); + + JsonObject embed = buildEmbed( + emoji + " " + severity + " – " + alertType.replace("_", " "), + message, color); + + JsonArray fields = new JsonArray(); + fields.add(field("🖥 Server", serverName, true)); + fields.add(field("🕐 Zeitpunkt", LocalDateTime.now().format(DT), true)); + + // Performance-Snapshot für den betroffenen Server + ServerData data = plugin.getDataStore().get(serverName); + if (data != null) { + fields.add(field("⚡ TPS", String.format("%.2f", data.getTps()), true)); + fields.add(field("⏱ MSPT", String.format("%.2f ms", data.getMspt()), true)); + fields.add(field("💾 RAM", + data.getRamUsedMb() + " MB / " + data.getRamMaxMb() + " MB", true)); + fields.add(field("👥 Online", data.getOnlinePlayers() + " Spieler", true)); + } + + // Netzwerk-Überblick + fields.add(field("🌐 Netzwerk gesamt", + plugin.getDataStore().getTotalOnlinePlayers() + " Spieler | " + + plugin.getDataStore().getOnlineServerCount() + " Server online", false)); + + embed.add("fields", fields); + send(embed); + } + + // ────────────────────────────────────────── + // CHAT ALERT + // ────────────────────────────────────────── + + public void sendChatAlert(String serverName, String playerName, String message, String reason) { + String emoji = reason.equals("SPAM") ? "📢" : "⚠️"; + String title = emoji + " Chat-Monitor – " + reason; + String desc = "Verdächtige Chat-Aktivität erkannt."; + + JsonObject embed = buildEmbed(title, desc, plugin.getBungeeConfig().getColorWarning()); + JsonArray fields = new JsonArray(); + fields.add(field("🖥 Server", serverName, true)); + fields.add(field("👤 Spieler", playerName, true)); + fields.add(field("💬 Nachricht", "`" + message + "`", false)); + fields.add(field("🔍 Grund", reason, true)); + fields.add(field("🕐 Zeit", LocalDateTime.now().format(DT), true)); + embed.add("fields", fields); + send(embed); + } + + // ────────────────────────────────────────── + // TÄGLICHER NETZWERK-REPORT + // ────────────────────────────────────────── + + public void scheduleDailyReport() { + String timeStr = plugin.getBungeeConfig().getDailyReportTime(); + try { + LocalTime reportTime = LocalTime.parse(timeStr, DateTimeFormatter.ofPattern("HH:mm")); + LocalTime now = LocalTime.now(); + long secondsUntil = now.isBefore(reportTime) + ? now.until(reportTime, java.time.temporal.ChronoUnit.SECONDS) + : now.until(reportTime.plusHours(24), java.time.temporal.ChronoUnit.SECONDS); + + plugin.getProxy().getScheduler().schedule(plugin, this::sendDailyReport, + secondsUntil, 86400L, TimeUnit.SECONDS); + plugin.getLogger().info("Täglicher Discord-Report geplant für " + timeStr); + } catch (Exception e) { + plugin.getLogger().warning("Ungültige Report-Zeit: " + timeStr); + } + } + + public void sendDailyReport() { + JsonObject embed = buildEmbed( + "📊 Täglicher Netzwerk-Statusbericht", + "Automatischer Tagesbericht für das gesamte Minecraft-Netzwerk.", + plugin.getBungeeConfig().getColorInfo()); + + JsonArray fields = new JsonArray(); + + // Netzwerk-Gesamtstatistik + fields.add(field("🌐 Netzwerk-Überblick", + "Online: **" + plugin.getDataStore().getTotalOnlinePlayers() + " Spieler**\n" + + "Server: " + plugin.getDataStore().getOnlineServerCount() + + "/" + plugin.getDataStore().getServerCount() + " online\n" + + "Ø TPS: " + String.format("%.2f", plugin.getDataStore().getNetworkAverageTps()) + "\n" + + "Min TPS: " + String.format("%.2f", plugin.getDataStore().getLowestTps()) + "\n" + + "RAM gesamt: " + plugin.getDataStore().getTotalRamUsedMb() + + " MB / " + plugin.getDataStore().getTotalRamMaxMb() + " MB", + false)); + + // Pro-Server-Daten + for (ServerData data : plugin.getDataStore().getAll()) { + String status = data.isOnline() ? "🟢" : "🔴"; + String content; + if (data.isOnline()) { + content = "TPS: **" + String.format("%.2f", data.getTps()) + "**" + + " | MSPT: " + String.format("%.2f", data.getMspt()) + "ms" + + "\nRAM: " + data.getRamUsedMb() + "/" + data.getRamMaxMb() + " MB" + + " | Spieler: " + data.getOnlinePlayers() + + "\nEntities: " + data.getTotalEntities() + + " | Chunks: " + data.getLoadedChunks(); + } else { + content = "❌ Offline"; + } + fields.add(field(status + " " + data.getServerName(), content, false)); + } + + fields.add(field("🕐 Erstellt", LocalDateTime.now().format(DT), false)); + + JsonObject footer = new JsonObject(); + footer.addProperty("text", "ServerPulse Network Monitoring"); + embed.add("footer", footer); + embed.add("fields", fields); + send(embed); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private JsonObject buildEmbed(String title, String description, int color) { + JsonObject embed = new JsonObject(); + embed.addProperty("title", title); + embed.addProperty("description", description); + embed.addProperty("color", color); + return embed; + } + + private JsonObject field(String name, String value, boolean inline) { + JsonObject f = new JsonObject(); + f.addProperty("name", name); + f.addProperty("value", value.isEmpty() ? "N/A" : value); + f.addProperty("inline", inline); + return f; + } + + private void send(JsonObject embed) { + String url = plugin.getBungeeConfig().getDiscordWebhookUrl(); + if (url == null || url.contains("YOUR_WEBHOOK")) return; + + JsonObject payload = new JsonObject(); + payload.addProperty("username", plugin.getBungeeConfig().getDiscordBotName()); + String avatar = plugin.getBungeeConfig().getDiscordBotAvatar(); + if (!avatar.isEmpty()) payload.addProperty("avatar_url", avatar); + + JsonArray embeds = new JsonArray(); + embeds.add(embed); + payload.add("embeds", embeds); + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try (Response response = httpClient.newCall(new Request.Builder() + .url(url) + .post(RequestBody.create(payload.toString(), MediaType.get("application/json"))) + .build()).execute()) { + if (!response.isSuccessful()) + plugin.getLogger().warning("Discord Webhook Fehler: " + response.code()); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Discord Verbindungsfehler: " + e.getMessage()); + } + }); + } +} diff --git a/src/main/java/de/serverpulse/bungee/listeners/BungeePlayerListener.java b/src/main/java/de/serverpulse/bungee/listeners/BungeePlayerListener.java new file mode 100644 index 0000000..7857b31 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/listeners/BungeePlayerListener.java @@ -0,0 +1,156 @@ +package de.serverpulse.bungee.listeners; + +import de.serverpulse.bungee.BungeePlugin; +import net.md_5.bungee.api.event.*; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.event.EventPriority; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.List; +import java.util.ArrayList; + +/** + * Lauscht auf BungeeCord-Events: + * - Login/Logout (Proxy-Ebene) + * - Server-Wechsel + * - Chat-Events (Spam + Suspicious Patterns) + */ +public class BungeePlayerListener implements Listener { + + private final BungeePlugin plugin; + + // Spam-Erkennung: UUID → Liste der letzten Nachrichten-Zeitpunkte (ms) + private final Map> chatTimestamps = new ConcurrentHashMap<>(); + + // Kompilierte Pattern für Suspicious-Chat-Erkennung + private final List suspiciousPatterns = new ArrayList<>(); + + public BungeePlayerListener(BungeePlugin plugin) { + this.plugin = plugin; + compileSuspiciousPatterns(); + } + + // ── Login ───────────────────────────────── + + @EventHandler(priority = EventPriority.LOWEST) + public void onLogin(PostLoginEvent event) { + plugin.debug("Spieler verbunden (Proxy): " + event.getPlayer().getName() + + " | Netzwerk-Spieler: " + plugin.getProxy().getOnlineCount()); + } + + // ── Logout ──────────────────────────────── + + @EventHandler(priority = EventPriority.LOWEST) + public void onDisconnect(PlayerDisconnectEvent event) { + chatTimestamps.remove(event.getPlayer().getUniqueId().toString()); + plugin.debug("Spieler getrennt (Proxy): " + event.getPlayer().getName()); + } + + // ── Server-Wechsel ──────────────────────── + + @EventHandler(priority = EventPriority.HIGHEST) + public void onServerSwitch(ServerSwitchEvent event) { + String from = event.getFrom() != null ? event.getFrom().getName() : "Proxy"; + String to = event.getPlayer().getServer() != null + ? event.getPlayer().getServer().getInfo().getName() : "?"; + + plugin.debug("Server-Wechsel: " + event.getPlayer().getName() + + " | " + from + " → " + to); + } + + // ── Chat-Monitoring ─────────────────────── + + @EventHandler(priority = EventPriority.HIGHEST) + public void onChat(ChatEvent event) { + if (event.isCommand()) return; + if (!plugin.getBungeeConfig().isChatMonitoringEnabled()) return; + + String message = event.getMessage(); + String playerUuid = event.getSender() instanceof net.md_5.bungee.api.connection.ProxiedPlayer p + ? p.getUniqueId().toString() : "unknown"; + String playerName = event.getSender() instanceof net.md_5.bungee.api.connection.ProxiedPlayer p + ? p.getName() : "unknown"; + String serverName = event.getSender() instanceof net.md_5.bungee.api.connection.ProxiedPlayer p + && p.getServer() != null + ? p.getServer().getInfo().getName() : "unknown"; + + // Spam-Erkennung + if (plugin.getBungeeConfig().isSpamDetectionEnabled()) { + if (isSpam(playerUuid)) { + plugin.debug("Spam erkannt von: " + playerName); + // Über PluginMessageHandler wird das bereits vom Spigot-Server gemeldet + // Proxy-seitig loggen wir es auch + plugin.getLogger().warning("[CHAT-SPAM] " + serverName + + " | " + playerName + ": " + message); + if (plugin.getBungeeConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendChatAlert(serverName, playerName, message, "SPAM"); + } + return; + } + } + + // Suspicious-Pattern-Erkennung + for (Pattern pattern : suspiciousPatterns) { + if (pattern.matcher(message).find()) { + plugin.getLogger().warning("[CHAT-SUSPICIOUS] " + serverName + + " | " + playerName + ": " + message); + if (plugin.getBungeeConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendChatAlert(serverName, playerName, message, "SUSPICIOUS"); + } + break; + } + } + } + + // ── Server-Connect/Disconnect ───────────── + + @EventHandler + public void onServerConnect(ServerConnectedEvent event) { + String serverName = event.getServer().getInfo().getName(); + plugin.getDataStore().getOrCreate(serverName); + } + + @EventHandler + public void onServerDisconnect(ServerDisconnectEvent event) { + String serverName = event.getTarget().getName(); + // Nur markieren wenn wirklich kein Spieler mehr drauf ist + long playersOnServer = plugin.getProxy().getPlayers().stream() + .filter(p -> p.getServer() != null + && p.getServer().getInfo().getName().equals(serverName)) + .count(); + if (playersOnServer == 0) { + plugin.debug("Server ohne Spieler: " + serverName); + } + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private boolean isSpam(String playerUuid) { + long now = System.currentTimeMillis(); + List timestamps = chatTimestamps.computeIfAbsent(playerUuid, k -> new ArrayList<>()); + + // Alte Einträge entfernen (> 1 Sekunde) + timestamps.removeIf(t -> now - t > 1000); + timestamps.add(now); + + int limit = plugin.getBungeeConfig().getSpamMessagesPerSecond(); + return timestamps.size() > limit; + } + + private void compileSuspiciousPatterns() { + List rawPatterns = plugin.getBungeeConfig().getSuspiciousPatterns(); + for (String raw : rawPatterns) { + try { + suspiciousPatterns.add(Pattern.compile(raw)); + } catch (Exception e) { + plugin.getLogger().warning("Ungültiges Regex-Pattern: " + raw); + } + } + plugin.getLogger().info(suspiciousPatterns.size() + " Chat-Pattern(s) geladen."); + } +} diff --git a/src/main/java/de/serverpulse/bungee/models/ServerData.java b/src/main/java/de/serverpulse/bungee/models/ServerData.java new file mode 100644 index 0000000..4ae0ee6 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/models/ServerData.java @@ -0,0 +1,109 @@ +package de.serverpulse.bungee.models; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * Hält alle empfangenen Monitoring-Daten eines einzelnen Sub-Servers. + * Wird über den Plugin Messaging Channel vom Spigot-Plugin befüllt. + */ +public class ServerData { + + private final String serverName; + + // Performance + private double tps = 20.0; + private double mspt = 0.0; + private long ramUsedMb = 0; + private long ramMaxMb = 0; + private double ramPercent = 0.0; + + // Spieler + private int onlinePlayers = 0; + private int loadedChunks = 0; + + // Entities (Welt → Kategorie → Anzahl) + private Map> entityData = new HashMap<>(); + + // Alerts (letzte X Alerts des Servers) + private String lastAlert = ""; + private String lastAlertSeverity = ""; + + // Timestamps + private LocalDateTime lastUpdate = null; + private LocalDateTime connectedSince = LocalDateTime.now(); + + // Status + private boolean online = false; + + public ServerData(String serverName) { + this.serverName = serverName; + } + + // ── Performance ────────────────────────── + public void updatePerformance(double tps, double mspt, + long ramUsedMb, long ramMaxMb, + double ramPercent, int onlinePlayers, int loadedChunks) { + this.tps = tps; + this.mspt = mspt; + this.ramUsedMb = ramUsedMb; + this.ramMaxMb = ramMaxMb; + this.ramPercent = ramPercent; + this.onlinePlayers = onlinePlayers; + this.loadedChunks = loadedChunks; + this.lastUpdate = LocalDateTime.now(); + this.online = true; + } + + // ── Entity-Daten ───────────────────────── + public void updateEntityData(String worldName, int monsters, int animals, + int waterMobs, int villagers, int armorStands, + int hopperMinecarts, int items, int players, + int other, int total) { + Map worldEntities = new HashMap<>(); + worldEntities.put("monsters", monsters); + worldEntities.put("animals", animals); + worldEntities.put("water_mobs", waterMobs); + worldEntities.put("villagers", villagers); + worldEntities.put("armor_stands", armorStands); + worldEntities.put("hopper_minecarts", hopperMinecarts); + worldEntities.put("items", items); + worldEntities.put("players", players); + worldEntities.put("other", other); + worldEntities.put("total", total); + this.entityData.put(worldName, worldEntities); + } + + // ── Alert ──────────────────────────────── + public void updateAlert(String message, String severity) { + this.lastAlert = message; + this.lastAlertSeverity = severity; + } + + public void markOffline() { + this.online = false; + } + + // ── Getter ─────────────────────────────── + public String getServerName() { return serverName; } + public double getTps() { return tps; } + public double getMspt() { return mspt; } + public long getRamUsedMb() { return ramUsedMb; } + public long getRamMaxMb() { return ramMaxMb; } + public double getRamPercent() { return ramPercent; } + public int getOnlinePlayers() { return onlinePlayers; } + public int getLoadedChunks() { return loadedChunks; } + public Map> getEntityData() { return entityData; } + public String getLastAlert() { return lastAlert; } + public String getLastAlertSeverity() { return lastAlertSeverity; } + public LocalDateTime getLastUpdate() { return lastUpdate; } + public LocalDateTime getConnectedSince() { return connectedSince; } + public boolean isOnline() { return online; } + + public int getTotalEntities() { + return entityData.values().stream() + .mapToInt(m -> m.getOrDefault("total", 0)) + .sum(); + } +} diff --git a/src/main/java/de/serverpulse/bungee/network/BungeeAlertManager.java b/src/main/java/de/serverpulse/bungee/network/BungeeAlertManager.java new file mode 100644 index 0000000..00dcfcb --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/network/BungeeAlertManager.java @@ -0,0 +1,85 @@ +package de.serverpulse.bungee.network; + +import de.serverpulse.network.NetworkMessage; +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Proxy-seitiger Alert Manager. + * Prüft Grenzwerte für alle Sub-Server und verhindert Alert-Spam via Cooldown. + */ +public class BungeeAlertManager { + + private final BungeePlugin plugin; + private final Map cooldowns = new ConcurrentHashMap<>(); + private static final long COOLDOWN_MS = 5 * 60 * 1000L; + + public BungeeAlertManager(BungeePlugin plugin) { + this.plugin = plugin; + } + + /** + * Prüft Performance-Grenzwerte eines Sub-Servers und löst Alerts aus. + */ + public void checkServerThresholds(String serverName, ServerData data) { + double tps = data.getTps(); + double mspt = data.getMspt(); + double ramPercent = data.getRamPercent(); + + if (tps < plugin.getBungeeConfig().getTpsCritical()) { + trigger(serverName + "_TPS_CRIT", "CRITICAL", + serverName, "TPS_CRITICAL", + String.format("Kritische TPS auf %s: %.2f", serverName, tps)); + } else if (tps < plugin.getBungeeConfig().getTpsWarning()) { + trigger(serverName + "_TPS_WARN", "WARNING", + serverName, "TPS_WARNING", + String.format("Niedrige TPS auf %s: %.2f", serverName, tps)); + } + + if (mspt > plugin.getBungeeConfig().getMsptCritical()) { + trigger(serverName + "_MSPT_CRIT", "CRITICAL", + serverName, "MSPT_CRITICAL", + String.format("Kritische MSPT auf %s: %.2f ms", serverName, mspt)); + } + + if (ramPercent > plugin.getBungeeConfig().getRamCritical()) { + trigger(serverName + "_RAM_CRIT", "CRITICAL", + serverName, "RAM_CRITICAL", + String.format("Kritische RAM-Auslastung auf %s: %.1f%%", serverName, ramPercent)); + } else if (ramPercent > plugin.getBungeeConfig().getRamWarning()) { + trigger(serverName + "_RAM_WARN", "WARNING", + serverName, "RAM_WARNING", + String.format("Hohe RAM-Auslastung auf %s: %.1f%%", serverName, ramPercent)); + } + + // Netzwerk-Spieler prüfen + int totalPlayers = plugin.getDataStore().getTotalOnlinePlayers(); + int maxPlayers = plugin.getProxy().getConfig().getPlayerLimit(); + if (maxPlayers > 0) { + double fillPercent = (double) totalPlayers / maxPlayers * 100; + if (fillPercent >= plugin.getBungeeConfig().getNetworkPlayersCritical()) { + trigger("NETWORK_PLAYERS_CRIT", "CRITICAL", + "network", "NETWORK_PLAYERS_CRITICAL", + String.format("Netzwerk fast voll: %d/%d Spieler (%.1f%%)", + totalPlayers, maxPlayers, fillPercent)); + } + } + } + + private void trigger(String cooldownKey, String severity, + String serverName, String alertType, String message) { + long now = System.currentTimeMillis(); + if (cooldowns.containsKey(cooldownKey) + && now - cooldowns.get(cooldownKey) < COOLDOWN_MS) return; + + cooldowns.put(cooldownKey, now); + plugin.getLogger().warning("[ALERT][" + severity + "] " + message); + + if (plugin.getBungeeConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendServerAlert(serverName, alertType, severity, message); + } + } +} diff --git a/src/main/java/de/serverpulse/bungee/network/NetworkDataStore.java b/src/main/java/de/serverpulse/bungee/network/NetworkDataStore.java new file mode 100644 index 0000000..30eb40c --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/network/NetworkDataStore.java @@ -0,0 +1,85 @@ +package de.serverpulse.bungee.network; + +import de.serverpulse.bungee.models.ServerData; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Zentraler In-Memory-Datenspeicher für alle Sub-Server-Daten. + * Thread-safe durch ConcurrentHashMap. + */ +public class NetworkDataStore { + + // serverName → ServerData + private final Map servers = new ConcurrentHashMap<>(); + + /** + * Gibt ServerData für einen Server zurück, erstellt ihn bei Bedarf. + */ + public ServerData getOrCreate(String serverName) { + return servers.computeIfAbsent(serverName, ServerData::new); + } + + public ServerData get(String serverName) { + return servers.get(serverName); + } + + public Collection getAll() { + return Collections.unmodifiableCollection(servers.values()); + } + + public int getServerCount() { + return servers.size(); + } + + public int getTotalOnlinePlayers() { + return servers.values().stream() + .filter(ServerData::isOnline) + .mapToInt(ServerData::getOnlinePlayers) + .sum(); + } + + public double getNetworkAverageTps() { + return servers.values().stream() + .filter(ServerData::isOnline) + .mapToDouble(ServerData::getTps) + .average() + .orElse(20.0); + } + + public double getLowestTps() { + return servers.values().stream() + .filter(ServerData::isOnline) + .mapToDouble(ServerData::getTps) + .min() + .orElse(20.0); + } + + public long getTotalRamUsedMb() { + return servers.values().stream() + .filter(ServerData::isOnline) + .mapToLong(ServerData::getRamUsedMb) + .sum(); + } + + public long getTotalRamMaxMb() { + return servers.values().stream() + .filter(ServerData::isOnline) + .mapToLong(ServerData::getRamMaxMb) + .sum(); + } + + public int getOnlineServerCount() { + return (int) servers.values().stream() + .filter(ServerData::isOnline) + .count(); + } + + public void markOffline(String serverName) { + ServerData data = servers.get(serverName); + if (data != null) data.markOffline(); + } +} diff --git a/src/main/java/de/serverpulse/bungee/network/PluginMessageHandler.java b/src/main/java/de/serverpulse/bungee/network/PluginMessageHandler.java new file mode 100644 index 0000000..a722d49 --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/network/PluginMessageHandler.java @@ -0,0 +1,143 @@ +package de.serverpulse.bungee.network; + +import de.serverpulse.network.NetworkMessage; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import de.serverpulse.bungee.BungeePlugin; +import de.serverpulse.bungee.models.ServerData; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +/** + * Empfängt Plugin Messaging Channel Nachrichten von den Spigot Sub-Servern. + * + * Kanal-Schema: de.serverpulse.network.NetworkMessage.CHANNEL + * + * Nachrichten-Typen (JSON): + * - PERFORMANCE → TPS, MSPT, RAM, Spieler, Chunks + * - ENTITY → Entity-Daten einer Welt + * - ALERT → Warnung von einem Sub-Server + * - CHAT_EVENT → Verdächtige Chat-Nachricht + * - HEARTBEAT → Server ist noch online + */ +public class PluginMessageHandler implements Listener { + + + + private final BungeePlugin plugin; + private final Gson gson = new Gson(); + + public PluginMessageHandler(BungeePlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onPluginMessage(PluginMessageEvent event) { + if (!event.getTag().equals(NetworkMessage.CHANNEL)) return; + if (!(event.getSender() instanceof net.md_5.bungee.api.connection.Server server)) return; + + String serverName = server.getInfo().getName(); + + try (DataInputStream in = new DataInputStream( + new ByteArrayInputStream(event.getData()))) { + + // Erste Bytes = JSON-String + byte[] jsonBytes = event.getData(); + String json = new String(jsonBytes, StandardCharsets.UTF_8); + + JsonObject msg = gson.fromJson(json, JsonObject.class); + String type = msg.get("type").getAsString(); + + plugin.debug("Nachricht von " + serverName + ": " + type); + + switch (type) { + case "PERFORMANCE" -> handlePerformance(serverName, msg); + case "ENTITY" -> handleEntity(serverName, msg); + case "ALERT" -> handleAlert(serverName, msg); + case "CHAT_EVENT" -> handleChatEvent(serverName, msg); + case "HEARTBEAT" -> handleHeartbeat(serverName); + default -> plugin.debug("Unbekannter Nachrichtentyp: " + type); + } + + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Verarbeiten der Nachricht von " + + serverName + ": " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // HANDLER + // ────────────────────────────────────────── + + private void handlePerformance(String serverName, JsonObject msg) { + ServerData data = plugin.getDataStore().getOrCreate(serverName); + data.updatePerformance( + msg.get("tps").getAsDouble(), + msg.get("mspt").getAsDouble(), + msg.get("ram_used_mb").getAsLong(), + msg.get("ram_max_mb").getAsLong(), + msg.get("ram_percent").getAsDouble(), + msg.get("online_players").getAsInt(), + msg.get("loaded_chunks").getAsInt() + ); + + // Proxy-seitige Grenzwert-Prüfung + plugin.getAlertManager().checkServerThresholds(serverName, data); + } + + private void handleEntity(String serverName, JsonObject msg) { + ServerData data = plugin.getDataStore().getOrCreate(serverName); + data.updateEntityData( + msg.get("world").getAsString(), + msg.get("monsters").getAsInt(), + msg.get("animals").getAsInt(), + msg.get("water_mobs").getAsInt(), + msg.get("villagers").getAsInt(), + msg.get("armor_stands").getAsInt(), + msg.get("hopper_minecarts").getAsInt(), + msg.get("items").getAsInt(), + msg.get("players").getAsInt(), + msg.get("other").getAsInt(), + msg.get("total").getAsInt() + ); + } + + private void handleAlert(String serverName, JsonObject msg) { + String alertMsg = msg.get("message").getAsString(); + String severity = msg.get("severity").getAsString(); + String alertType = msg.get("alert_type").getAsString(); + + ServerData data = plugin.getDataStore().getOrCreate(serverName); + data.updateAlert(alertMsg, severity); + + plugin.getLogger().warning("[" + serverName + "] [" + severity + "] " + alertMsg); + + // An Discord weiterleiten + if (plugin.getBungeeConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendServerAlert(serverName, alertType, severity, alertMsg); + } + } + + private void handleChatEvent(String serverName, JsonObject msg) { + String playerName = msg.get("player").getAsString(); + String message = msg.get("message").getAsString(); + String reason = msg.get("reason").getAsString(); // SUSPICIOUS / SPAM + + plugin.getLogger().warning("[CHAT-MONITOR] " + serverName + + " | " + playerName + ": " + message + " (" + reason + ")"); + + if (plugin.getBungeeConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendChatAlert(serverName, playerName, message, reason); + } + } + + private void handleHeartbeat(String serverName) { + plugin.getDataStore().getOrCreate(serverName); + plugin.debug("Heartbeat von: " + serverName); + } +} diff --git a/src/main/java/de/serverpulse/bungee/utils/BungeeConfig.java b/src/main/java/de/serverpulse/bungee/utils/BungeeConfig.java new file mode 100644 index 0000000..a0f8c3f --- /dev/null +++ b/src/main/java/de/serverpulse/bungee/utils/BungeeConfig.java @@ -0,0 +1,103 @@ +package de.serverpulse.bungee.utils; + +import de.serverpulse.bungee.BungeePlugin; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +import java.io.*; +import java.nio.file.Files; +import java.util.List; + +/** + * Konfigurationsverwaltung für das BungeeCord-Plugin. + */ +public class BungeeConfig { + + private final BungeePlugin plugin; + private Configuration config; + + public BungeeConfig(BungeePlugin plugin) { + this.plugin = plugin; + } + + public void load() { + try { + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + + File configFile = new File(plugin.getDataFolder(), "config.yml"); + if (!configFile.exists()) { + try (InputStream in = plugin.getResourceAsStream("config.yml")) { + Files.copy(in, configFile.toPath()); + } + } + config = ConfigurationProvider.getProvider(YamlConfiguration.class) + .load(configFile); + } catch (IOException e) { + plugin.getLogger().severe("Fehler beim Laden der Konfiguration: " + e.getMessage()); + } + } + + // ── General ────────────────────────────── + public boolean isDebugMode() { return config.getBoolean("general.debug", false); } + public int getPollInterval() { return config.getInt("general.poll-interval", 30); } + + // ── Discord ────────────────────────────── + public boolean isDiscordEnabled() { return config.getBoolean("discord.enabled", false); } + public String getDiscordWebhookUrl() { return config.getString("discord.webhook-url", ""); } + public String getDiscordBotName() { return config.getString("discord.bot-name", "ServerPulse Network"); } + public String getDiscordBotAvatar() { return config.getString("discord.bot-avatar", ""); } + public boolean isDailyReportEnabled() { return config.getBoolean("discord.daily-report.enabled", true); } + public String getDailyReportTime() { return config.getString("discord.daily-report.time", "08:00"); } + public int getColorInfo() { return config.getInt("discord.colors.info", 3447003); } + public int getColorWarning() { return config.getInt("discord.colors.warning", 16776960); } + public int getColorCritical() { return config.getInt("discord.colors.critical", 16711680); } + public int getColorSuccess() { return config.getInt("discord.colors.success", 65280); } + + // ── Thresholds ─────────────────────────── + public double getTpsWarning() { return config.getDouble("thresholds.tps-warning", 18.0); } + public double getTpsCritical() { return config.getDouble("thresholds.tps-critical", 15.0); } + public double getMsptWarning() { return config.getDouble("thresholds.mspt-warning", 40.0); } + public double getMsptCritical() { return config.getDouble("thresholds.mspt-critical", 50.0); } + public int getRamWarning() { return config.getInt("thresholds.ram-warning", 75); } + public int getRamCritical() { return config.getInt("thresholds.ram-critical", 90); } + public int getNetworkPlayersWarning() { return config.getInt("thresholds.network-players-warning", 80); } + public int getNetworkPlayersCritical() { return config.getInt("thresholds.network-players-critical", 95); } + + // ── REST API ───────────────────────────── + public boolean isRestApiEnabled() { return config.getBoolean("rest-api.enabled", false); } + public String getRestApiHost() { return config.getString("rest-api.host", "0.0.0.0"); } + public int getRestApiPort() { return config.getInt("rest-api.port", 8081); } + public String getRestApiKey() { return config.getString("rest-api.api-key", ""); } + + // ── Formate ────────────────────────────── + public boolean isPrometheusEnabled() { return config.getBoolean("rest-api.formats.prometheus.enabled", true); } + public String getPrometheusEndpoint() { return config.getString("rest-api.formats.prometheus.endpoint", "/metrics"); } + + public boolean isSimpleJsonEnabled() { return config.getBoolean("rest-api.formats.simple-json.enabled", true); } + public String getSimpleJsonEndpoint() { return config.getString("rest-api.formats.simple-json.endpoint", "/grafana"); } + + public boolean isInfluxEnabled() { return config.getBoolean("rest-api.formats.influxdb.enabled", true); } + public String getInfluxEndpoint() { return config.getString("rest-api.formats.influxdb.endpoint", "/influx"); } + public String getInfluxMeasurement() { return config.getString("rest-api.formats.influxdb.measurement", "serverpulse"); } + + public boolean isInfluxPushEnabled() { return config.getBoolean("rest-api.formats.influxdb.push.enabled", false); } + public String getInfluxPushUrl() { return config.getString("rest-api.formats.influxdb.push.url", ""); } + public String getInfluxPushToken() { return config.getString("rest-api.formats.influxdb.push.token", ""); } + public String getInfluxPushOrg() { return config.getString("rest-api.formats.influxdb.push.org", ""); } + public String getInfluxPushBucket() { return config.getString("rest-api.formats.influxdb.push.bucket", "serverpulse"); } + + public boolean isJsonApiEnabled() { return config.getBoolean("rest-api.formats.json.enabled", true); } + public String getJsonApiEndpoint() { return config.getString("rest-api.formats.json.endpoint", "/api"); } + + // ── Chat-Monitoring ────────────────────── + public boolean isChatMonitoringEnabled() { return config.getBoolean("chat-monitoring.enabled", true); } + public boolean isSpamDetectionEnabled() { return config.getBoolean("chat-monitoring.spam.enabled", true); } + public int getSpamMessagesPerSecond() { return config.getInt("chat-monitoring.spam.messages-per-second", 5); } + + @SuppressWarnings("unchecked") + public List getSuspiciousPatterns() { + return (List) config.getList("chat-monitoring.suspicious-patterns", + List.of("(?i)(hack|cheat|xray)")); + } +} diff --git a/src/main/java/de/serverpulse/commands/ServerPulseCommand.java b/src/main/java/de/serverpulse/commands/ServerPulseCommand.java new file mode 100644 index 0000000..bb3b97d --- /dev/null +++ b/src/main/java/de/serverpulse/commands/ServerPulseCommand.java @@ -0,0 +1,429 @@ +package de.serverpulse.commands; + +import de.serverpulse.ServerPulse; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; +import de.serverpulse.utils.MessageUtil; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Verarbeitet alle /serverpulse (Alias: /sp, /pulse) Unterbefehle. + */ +public class ServerPulseCommand implements CommandExecutor, TabCompleter { + + private final ServerPulse plugin; + + public ServerPulseCommand(ServerPulse plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + String prefix = plugin.getConfigManager().getPrefix(); + + if (args.length == 0) { + sendHelp(sender, prefix, label); + return true; + } + + switch (args[0].toLowerCase()) { + case "status" -> handleStatus(sender, prefix); + case "report" -> handleReport(sender, prefix); + case "world" -> handleWorld(sender, prefix, args); + case "entities" -> handleEntities(sender, prefix, args); + case "reload" -> handleReload(sender, prefix); + case "debug" -> handleDebug(sender, prefix); + case "clear" -> handleClear(sender, prefix, args); + default -> sendHelp(sender, prefix, label); + } + + return true; + } + + // ────────────────────────────────────────── + // /sp status + // ────────────────────────────────────────── + + private void handleStatus(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.status")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("ServerPulse Status"))); + + PerformanceSnapshot snap = plugin.getPerformanceMonitor().getLastSnapshot(); + if (snap == null) { + sender.sendMessage(prefix + "&eNoch keine Daten verfügbar. Bitte warte einen Moment..."); + return; + } + + double tps = snap.getTps(); + double mspt = snap.getMspt(); + long ramUsed = snap.getRamUsedMb(); + long ramMax = snap.getRamMaxMb(); + + sender.sendMessage(MessageUtil.colorize( + prefix + "&7TPS: " + MessageUtil.formatTps(tps) + " &8| &7MSPT: " + MessageUtil.formatMspt(mspt) + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7RAM: " + MessageUtil.formatRam(ramUsed, ramMax) + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7RAM-Auslastung: " + MessageUtil.progressBar(ramUsed, ramMax, 20) + + " &7" + ramUsed + "/" + ramMax + "MB" + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Spieler: &f" + snap.getOnlinePlayers() + + " &8| &7Welten: &f" + snap.getLoadedWorlds() + + " &8| &7Chunks: &f" + snap.getLoadedChunks() + )); + + if (plugin.getDatabaseManager().isConnected()) { + double avgTps15m = plugin.getDatabaseManager().getAverageTps(15); + double avgTps60m = plugin.getDatabaseManager().getAverageTps(60); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Ø TPS: &715min=&f" + String.format("%.2f", avgTps15m) + + " &860min=&f" + String.format("%.2f", avgTps60m) + )); + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp report + // ────────────────────────────────────────── + + private void handleReport(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.report")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + sender.sendMessage(prefix + "&aErstelle Report..."); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("ServerPulse Report"))); + + // Performance + PerformanceSnapshot perf = plugin.getPerformanceMonitor().getLastSnapshot(); + if (perf != null) { + sender.sendMessage(MessageUtil.colorize(prefix + "&b=== Performance ===")); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7TPS: " + MessageUtil.formatTps(perf.getTps()) + + " MSPT: " + MessageUtil.formatMspt(perf.getMspt()) + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7RAM: " + MessageUtil.formatRam(perf.getRamUsedMb(), perf.getRamMaxMb()) + )); + } + + // Datenbank-Statistiken + if (plugin.getDatabaseManager().isConnected()) { + sender.sendMessage(MessageUtil.colorize(prefix + "&b=== Warnungen (24h) ===")); + int critAlerts = plugin.getDatabaseManager().getAlertCount(24, "CRITICAL"); + int warnAlerts = plugin.getDatabaseManager().getAlertCount(24, "WARNING"); + sender.sendMessage(MessageUtil.colorize( + prefix + "&c" + critAlerts + " kritisch &e" + warnAlerts + " Warnungen" + )); + } + + // Entities + sender.sendMessage(MessageUtil.colorize(prefix + "&b=== Entities ===")); + for (Map.Entry entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + EntitySnapshot snap = entry.getValue(); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7" + entry.getKey() + ": &fGesamt=" + snap.getTotal() + + " Monster=" + MessageUtil.formatEntityCount( + snap.getMonsters(), + plugin.getConfigManager().getWorldMonstersWarning(entry.getKey()), + plugin.getConfigManager().getWorldMonstersCritical(entry.getKey())) + )); + } + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveReport("MANUAL", sender.getName(), "Manueller Report erstellt."); + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + }); + } + + // ────────────────────────────────────────── + // /sp world + // ────────────────────────────────────────── + + private void handleWorld(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.world")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + if (args.length < 2) { + sender.sendMessage(prefix + "&cBenutzung: /sp world "); + sender.sendMessage(prefix + "&7Verfügbare Welten: &f" + + Bukkit.getWorlds().stream() + .map(World::getName) + .collect(Collectors.joining(", "))); + return; + } + + String worldName = args[1]; + World world = Bukkit.getWorld(worldName); + + if (world == null) { + sender.sendMessage(prefix + "&cWelt nicht gefunden: &f" + worldName); + return; + } + + String displayName = plugin.getConfigManager().getWorldDisplayName(worldName); + + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("Welt: " + displayName))); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Typ: &f" + world.getEnvironment().name() + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Geladene Chunks: &f" + world.getLoadedChunks().length + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Spieler: &f" + world.getPlayers().size() + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Entities gesamt: &f" + world.getEntities().size() + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Schwierigkeitsgrad: &f" + world.getDifficulty().name() + )); + + EntitySnapshot snap = plugin.getEntityMonitor().getLastSnapshot(worldName); + if (snap != null) { + sender.sendMessage(MessageUtil.colorize(prefix + "&b--- Letzter Entity-Snapshot ---")); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Monster: " + MessageUtil.formatEntityCount( + snap.getMonsters(), + plugin.getConfigManager().getWorldMonstersWarning(worldName), + plugin.getConfigManager().getWorldMonstersCritical(worldName)) + + " Tiere: &a" + snap.getAnimals() + + " Villager: " + MessageUtil.formatEntityCount( + snap.getVillagers(), + plugin.getConfigManager().getVillagersWarning(), + plugin.getConfigManager().getVillagersCritical()) + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Items: " + MessageUtil.formatEntityCount( + snap.getItems(), + plugin.getConfigManager().getItemsWarning(), + plugin.getConfigManager().getItemsCritical()) + + " ArmorStands: &7" + snap.getArmorStands() + + " HopperCarts: &7" + snap.getHopperMinecarts() + )); + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp entities [welt] + // ────────────────────────────────────────── + + private void handleEntities(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.entities")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("Entity-Übersicht"))); + + Map snapshots; + if (args.length >= 2) { + String worldName = args[1]; + EntitySnapshot snap = plugin.getEntityMonitor().getLastSnapshot(worldName); + if (snap == null) { + sender.sendMessage(prefix + "&cKeine Daten für Welt: &f" + worldName); + return; + } + snapshots = Map.of(worldName, snap); + } else { + snapshots = plugin.getEntityMonitor().getAllLastSnapshots(); + } + + if (snapshots.isEmpty()) { + sender.sendMessage(prefix + "&eNoch keine Entity-Daten verfügbar."); + return; + } + + for (Map.Entry entry : snapshots.entrySet()) { + EntitySnapshot snap = entry.getValue(); + String wName = entry.getKey(); + String displayName = plugin.getConfigManager().getWorldDisplayName(wName); + + sender.sendMessage(MessageUtil.colorize( + prefix + "&b" + displayName + " &8(" + wName + ")" + )); + sender.sendMessage(MessageUtil.colorize( + " &7Monster: " + MessageUtil.formatEntityCount( + snap.getMonsters(), + plugin.getConfigManager().getWorldMonstersWarning(wName), + plugin.getConfigManager().getWorldMonstersCritical(wName)) + + " &7Tiere: &a" + snap.getAnimals() + + " &7Wasser: &a" + snap.getWaterMobs() + )); + sender.sendMessage(MessageUtil.colorize( + " &7Villager: " + MessageUtil.formatEntityCount( + snap.getVillagers(), + plugin.getConfigManager().getVillagersWarning(), + plugin.getConfigManager().getVillagersCritical()) + + " &7Items: " + MessageUtil.formatEntityCount( + snap.getItems(), + plugin.getConfigManager().getItemsWarning(), + plugin.getConfigManager().getItemsCritical()) + + " &7Gesamt: &f" + snap.getTotal() + )); + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp reload + // ────────────────────────────────────────── + + private void handleReload(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.reload")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + plugin.reload(); + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("reload-success")); + } + + // ────────────────────────────────────────── + // /sp debug + // ────────────────────────────────────────── + + private void handleDebug(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.debug")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("Debug-Informationen"))); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Plugin-Version: &f" + plugin.getDescription().getVersion() + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Debug-Modus: " + (plugin.getConfigManager().isDebugMode() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Datenbank: " + (plugin.getDatabaseManager().isConnected() ? "&aVerbunden" : "&cGetrennt") + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Discord: " + (plugin.getConfigManager().isDiscordEnabled() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7REST API: " + (plugin.getConfigManager().isRestApiEnabled() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Trend-Analyse: " + (plugin.getConfigManager().isTrendAnalysisEnabled() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Überwachte Welten: &f" + Bukkit.getWorlds().size() + )); + sender.sendMessage(MessageUtil.colorize( + prefix + "&7Kritische Events (seit Start): &f" + plugin.getAlertManager().getCriticalEventCount() + )); + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp clear + // ────────────────────────────────────────── + + private void handleClear(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.admin")) { + sender.sendMessage(prefix + plugin.getConfigManager().getMessage("no-permission")); + return; + } + + if (args.length < 3) { + sender.sendMessage(prefix + "&cBenutzung: /sp clear "); + return; + } + + String type = args[1].toLowerCase(); + String worldName = args[2]; + + if (Bukkit.getWorld(worldName) == null) { + sender.sendMessage(prefix + "&cWelt nicht gefunden: &f" + worldName); + return; + } + + switch (type) { + case "items" -> { + sender.sendMessage(prefix + "&eStarte Item-Clear in &f" + worldName + "&e..."); + plugin.getAlertManager().triggerItemClear(worldName); + } + case "mobs" -> { + sender.sendMessage(prefix + "&eStarte Mob-Clear in &f" + worldName + "&e..."); + plugin.getAlertManager().triggerMobClear(worldName); + } + default -> sender.sendMessage(prefix + "&cUnbekannter Typ. Nutze &fitems &coder &fmobs&c."); + } + } + + // ────────────────────────────────────────── + // HILFE + // ────────────────────────────────────────── + + private void sendHelp(CommandSender sender, String prefix, String label) { + sender.sendMessage(MessageUtil.colorize(MessageUtil.header("ServerPulse Hilfe"))); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " status &8– &7Server-Performance-Übersicht")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " report &8– &7Vollständiger Statusbericht")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " world &8– &7Welt-Statistiken")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " entities [welt] &8– &7Entity-Übersicht")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " clear &8– &7Notfall-Clear")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " reload &8– &7Konfiguration neu laden")); + sender.sendMessage(MessageUtil.colorize(prefix + "&b/" + label + " debug &8– &7Debug-Informationen")); + sender.sendMessage(MessageUtil.colorize(MessageUtil.separator())); + } + + // ────────────────────────────────────────── + // TAB-COMPLETION + // ────────────────────────────────────────── + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List completions = new ArrayList<>(); + + if (args.length == 1) { + completions.addAll(Arrays.asList("status", "report", "world", "entities", "reload", "debug", "clear")); + } else if (args.length == 2) { + switch (args[0].toLowerCase()) { + case "world", "entities" -> + Bukkit.getWorlds().forEach(w -> completions.add(w.getName())); + case "clear" -> + completions.addAll(Arrays.asList("items", "mobs")); + } + } else if (args.length == 3 && args[0].equalsIgnoreCase("clear")) { + Bukkit.getWorlds().forEach(w -> completions.add(w.getName())); + } + + return completions.stream() + .filter(s -> s.toLowerCase().startsWith(args[args.length - 1].toLowerCase())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/de/serverpulse/database/DatabaseManager.java b/src/main/java/de/serverpulse/database/DatabaseManager.java new file mode 100644 index 0000000..92853ce --- /dev/null +++ b/src/main/java/de/serverpulse/database/DatabaseManager.java @@ -0,0 +1,446 @@ +package de.serverpulse.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.serverpulse.ServerPulse; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; + +import java.sql.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +/** + * Verwaltet die MySQL-Datenbankverbindung via HikariCP + * und stellt Methoden für alle CRUD-Operationen bereit. + */ +public class DatabaseManager { + + private final ServerPulse plugin; + private HikariDataSource dataSource; + + public DatabaseManager(ServerPulse plugin) { + this.plugin = plugin; + } + + // ────────────────────────────────────────── + // VERBINDUNG + // ────────────────────────────────────────── + + /** + * Verbindet mit der MySQL-Datenbank via HikariCP + */ + public boolean connect() { + try { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl("jdbc:mysql://" + + plugin.getConfigManager().getDbHost() + ":" + + plugin.getConfigManager().getDbPort() + "/" + + plugin.getConfigManager().getDbName() + + "?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8"); + hikariConfig.setUsername(plugin.getConfigManager().getDbUsername()); + hikariConfig.setPassword(plugin.getConfigManager().getDbPassword()); + + // Pool-Einstellungen + hikariConfig.setMaximumPoolSize(plugin.getConfigManager().getPoolMaxSize()); + hikariConfig.setMinimumIdle(plugin.getConfigManager().getPoolMinIdle()); + hikariConfig.setConnectionTimeout(plugin.getConfigManager().getPoolConnectionTimeout()); + hikariConfig.setIdleTimeout(plugin.getConfigManager().getPoolIdleTimeout()); + hikariConfig.setMaxLifetime(plugin.getConfigManager().getPoolMaxLifetime()); + hikariConfig.setPoolName("ServerPulse-Pool"); + + // Performance-Eigenschaften + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + hikariConfig.addDataSourceProperty("useServerPrepStmts", "true"); + + this.dataSource = new HikariDataSource(hikariConfig); + plugin.getLogger().info("Datenbankverbindung erfolgreich hergestellt!"); + return true; + + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "Datenbankverbindung fehlgeschlagen: " + e.getMessage(), e); + return false; + } + } + + /** + * Schließt den Connection Pool + */ + public void disconnect() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + plugin.getLogger().info("Datenbankverbindung getrennt."); + } + } + + /** + * Gibt eine Verbindung aus dem Pool zurück + */ + public Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + public boolean isConnected() { + return dataSource != null && !dataSource.isClosed(); + } + + // ────────────────────────────────────────── + // TABELLEN ERSTELLEN + // ────────────────────────────────────────── + + /** + * Erstellt alle benötigten Tabellen, falls nicht vorhanden + */ + public void createTables() { + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + + // server_metrics: TPS, MSPT, RAM, CPU, Spieler + stmt.execute(""" + CREATE TABLE IF NOT EXISTS server_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + tps DOUBLE NOT NULL, + mspt DOUBLE NOT NULL, + ram_used_mb BIGINT NOT NULL, + ram_max_mb BIGINT NOT NULL, + ram_percent DOUBLE NOT NULL, + online_players INT NOT NULL, + loaded_worlds INT NOT NULL, + loaded_chunks INT NOT NULL, + INDEX idx_recorded_at (recorded_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // world_metrics: Welt-spezifische Daten + stmt.execute(""" + CREATE TABLE IF NOT EXISTS world_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + world_name VARCHAR(100) NOT NULL, + loaded_chunks INT NOT NULL, + total_entities INT NOT NULL, + online_players INT NOT NULL, + INDEX idx_recorded_at (recorded_at), + INDEX idx_world_name (world_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // entity_metrics: Entity-Zahlen pro Welt und Kategorie + stmt.execute(""" + CREATE TABLE IF NOT EXISTS entity_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + world_name VARCHAR(100) NOT NULL, + monsters INT NOT NULL DEFAULT 0, + animals INT NOT NULL DEFAULT 0, + water_mobs INT NOT NULL DEFAULT 0, + villagers INT NOT NULL DEFAULT 0, + armor_stands INT NOT NULL DEFAULT 0, + hopper_minecarts INT NOT NULL DEFAULT 0, + items INT NOT NULL DEFAULT 0, + players INT NOT NULL DEFAULT 0, + other INT NOT NULL DEFAULT 0, + total INT NOT NULL DEFAULT 0, + INDEX idx_recorded_at (recorded_at), + INDEX idx_world_name (world_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // alert_history: Protokoll aller Warnungen + stmt.execute(""" + CREATE TABLE IF NOT EXISTS alert_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + alert_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL, + world_name VARCHAR(100), + message TEXT NOT NULL, + value_current DOUBLE, + value_threshold DOUBLE, + discord_sent BOOLEAN DEFAULT FALSE, + INDEX idx_created_at (created_at), + INDEX idx_severity (severity), + INDEX idx_alert_type (alert_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // report_history: Protokoll generierter Reports + stmt.execute(""" + CREATE TABLE IF NOT EXISTS report_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + report_type VARCHAR(50) NOT NULL, + generated_by VARCHAR(100) NOT NULL, + content TEXT, + discord_sent BOOLEAN DEFAULT FALSE, + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + plugin.getLogger().info("Datenbanktabellen erfolgreich erstellt/verifiziert."); + + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e); + } + } + + // ────────────────────────────────────────── + // SERVER METRICS + // ────────────────────────────────────────── + + /** + * Speichert einen Performance-Snapshot + */ + public void saveServerMetrics(PerformanceSnapshot snapshot) { + String sql = """ + INSERT INTO server_metrics + (recorded_at, tps, mspt, ram_used_mb, ram_max_mb, ram_percent, online_players, loaded_worlds, loaded_chunks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setTimestamp(1, Timestamp.valueOf(snapshot.getRecordedAt())); + ps.setDouble(2, snapshot.getTps()); + ps.setDouble(3, snapshot.getMspt()); + ps.setLong(4, snapshot.getRamUsedMb()); + ps.setLong(5, snapshot.getRamMaxMb()); + ps.setDouble(6, snapshot.getRamPercent()); + ps.setInt(7, snapshot.getOnlinePlayers()); + ps.setInt(8, snapshot.getLoadedWorlds()); + ps.setInt(9, snapshot.getLoadedChunks()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Server-Metriken: " + e.getMessage()); + } + } + + /** + * Gibt die letzten N Performance-Snapshots zurück + */ + public List getRecentServerMetrics(int limit) { + List results = new ArrayList<>(); + String sql = "SELECT * FROM server_metrics ORDER BY recorded_at DESC LIMIT ?"; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, limit); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + results.add(mapServerMetrics(rs)); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Server-Metriken: " + e.getMessage()); + } + return results; + } + + /** + * Durchschnittliche TPS der letzten X Minuten + */ + public double getAverageTps(int minutes) { + String sql = "SELECT AVG(tps) FROM server_metrics WHERE recorded_at >= NOW() - INTERVAL ? MINUTE"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, minutes); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getDouble(1); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Berechnen der Durchschnitts-TPS: " + e.getMessage()); + } + return 20.0; + } + + // ────────────────────────────────────────── + // ENTITY METRICS + // ────────────────────────────────────────── + + /** + * Speichert einen Entity-Snapshot + */ + public void saveEntityMetrics(EntitySnapshot snapshot) { + String sql = """ + INSERT INTO entity_metrics + (recorded_at, world_name, monsters, animals, water_mobs, villagers, + armor_stands, hopper_minecarts, items, players, other, total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setTimestamp(1, Timestamp.valueOf(snapshot.getRecordedAt())); + ps.setString(2, snapshot.getWorldName()); + ps.setInt(3, snapshot.getMonsters()); + ps.setInt(4, snapshot.getAnimals()); + ps.setInt(5, snapshot.getWaterMobs()); + ps.setInt(6, snapshot.getVillagers()); + ps.setInt(7, snapshot.getArmorStands()); + ps.setInt(8, snapshot.getHopperMinecarts()); + ps.setInt(9, snapshot.getItems()); + ps.setInt(10, snapshot.getPlayers()); + ps.setInt(11, snapshot.getOther()); + ps.setInt(12, snapshot.getTotal()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Entity-Metriken: " + e.getMessage()); + } + } + + /** + * Letzte Entity-Metriken für eine Welt + */ + public EntitySnapshot getLatestEntityMetrics(String worldName) { + String sql = "SELECT * FROM entity_metrics WHERE world_name = ? ORDER BY recorded_at DESC LIMIT 1"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, worldName); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return mapEntityMetrics(rs); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Entity-Metriken: " + e.getMessage()); + } + return null; + } + + /** + * Entity-Verlauf für Trendanalyse + */ + public List getEntityHistory(String worldName, int dataPoints) { + List results = new ArrayList<>(); + String sql = "SELECT * FROM entity_metrics WHERE world_name = ? ORDER BY recorded_at DESC LIMIT ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, worldName); + ps.setInt(2, dataPoints); + ResultSet rs = ps.executeQuery(); + while (rs.next()) results.add(mapEntityMetrics(rs)); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Entity-Historie: " + e.getMessage()); + } + return results; + } + + // ────────────────────────────────────────── + // ALERT HISTORY + // ────────────────────────────────────────── + + /** + * Speichert eine Warnung in der Datenbank + */ + public void saveAlert(String alertType, String severity, String worldName, + String message, Double currentValue, Double threshold) { + String sql = """ + INSERT INTO alert_history (alert_type, severity, world_name, message, value_current, value_threshold) + VALUES (?, ?, ?, ?, ?, ?) + """; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, alertType); + ps.setString(2, severity); + ps.setString(3, worldName); + ps.setString(4, message); + if (currentValue != null) ps.setDouble(5, currentValue); + else ps.setNull(5, Types.DOUBLE); + if (threshold != null) ps.setDouble(6, threshold); + else ps.setNull(6, Types.DOUBLE); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Warnung: " + e.getMessage()); + } + } + + /** + * Anzahl der Warnungen der letzten X Stunden + */ + public int getAlertCount(int hours, String severity) { + String sql = "SELECT COUNT(*) FROM alert_history WHERE created_at >= NOW() - INTERVAL ? HOUR AND severity = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, hours); + ps.setString(2, severity); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getInt(1); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Zählen der Warnungen: " + e.getMessage()); + } + return 0; + } + + // ────────────────────────────────────────── + // REPORT HISTORY + // ────────────────────────────────────────── + + /** + * Speichert einen generierten Report + */ + public void saveReport(String reportType, String generatedBy, String content) { + String sql = "INSERT INTO report_history (report_type, generated_by, content) VALUES (?, ?, ?)"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, reportType); + ps.setString(2, generatedBy); + ps.setString(3, content); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern des Reports: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // DATEN-BEREINIGUNG + // ────────────────────────────────────────── + + /** + * Löscht alte Daten basierend auf der konfigurierten Retention-Zeit + */ + public void cleanupOldData() { + int days = plugin.getConfigManager().getDataRetentionDays(); + if (days <= 0) return; + + String[] tables = {"server_metrics", "world_metrics", "entity_metrics", "alert_history", "report_history"}; + try (Connection conn = getConnection()) { + for (String table : tables) { + try (PreparedStatement ps = conn.prepareStatement( + "DELETE FROM " + table + " WHERE recorded_at < NOW() - INTERVAL ? DAY")) { + ps.setInt(1, days); + int deleted = ps.executeUpdate(); + if (deleted > 0) { + plugin.debug("Bereinigt: " + deleted + " Zeilen aus " + table); + } + } + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler bei der Datenbereinigung: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // MAPPER + // ────────────────────────────────────────── + + private PerformanceSnapshot mapServerMetrics(ResultSet rs) throws SQLException { + return new PerformanceSnapshot( + rs.getTimestamp("recorded_at").toLocalDateTime(), + rs.getDouble("tps"), + rs.getDouble("mspt"), + rs.getLong("ram_used_mb"), + rs.getLong("ram_max_mb"), + rs.getDouble("ram_percent"), + rs.getInt("online_players"), + rs.getInt("loaded_worlds"), + rs.getInt("loaded_chunks") + ); + } + + private EntitySnapshot mapEntityMetrics(ResultSet rs) throws SQLException { + return new EntitySnapshot( + rs.getTimestamp("recorded_at").toLocalDateTime(), + rs.getString("world_name"), + rs.getInt("monsters"), + rs.getInt("animals"), + rs.getInt("water_mobs"), + rs.getInt("villagers"), + rs.getInt("armor_stands"), + rs.getInt("hopper_minecarts"), + rs.getInt("items"), + rs.getInt("players"), + rs.getInt("other"), + rs.getInt("total") + ); + } +} diff --git a/src/main/java/de/serverpulse/discord/DiscordWebhook.java b/src/main/java/de/serverpulse/discord/DiscordWebhook.java new file mode 100644 index 0000000..9eb31b1 --- /dev/null +++ b/src/main/java/de/serverpulse/discord/DiscordWebhook.java @@ -0,0 +1,270 @@ +package de.serverpulse.discord; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import de.serverpulse.ServerPulse; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; +import de.serverpulse.utils.MessageUtil; +import okhttp3.*; + +import java.io.IOException; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * Verwaltet alle Discord-Webhook-Benachrichtigungen. + * Unterstützt Rich Embeds mit Farben, Feldern und Avataren. + */ +public class DiscordWebhook { + + private final ServerPulse plugin; + private final OkHttpClient httpClient; + + public DiscordWebhook(ServerPulse plugin) { + this.plugin = plugin; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + // ────────────────────────────────────────── + // ALERT SENDEN + // ────────────────────────────────────────── + + /** + * Sendet eine Warnung als Discord-Embed + */ + public void sendAlert(String alertType, AlertSeverity severity, String worldName, + String message, Double currentValue, Double threshold) { + if (!plugin.getConfigManager().isDiscordEnabled()) return; + + int color = switch (severity) { + case CRITICAL -> plugin.getConfigManager().getDiscordColorCritical(); + case WARNING -> plugin.getConfigManager().getDiscordColorWarning(); + default -> plugin.getConfigManager().getDiscordColorInfo(); + }; + + String emoji = switch (severity) { + case CRITICAL -> "🔴"; + case WARNING -> "🟡"; + default -> "🔵"; + }; + + JsonObject embed = createEmbed( + emoji + " " + severity.getName() + " – " + formatAlertType(alertType), + message, + color + ); + + // Felder hinzufügen + JsonArray fields = new JsonArray(); + + if (worldName != null) { + fields.add(createField("🌍 Welt", worldName, true)); + } + + if (currentValue != null) { + fields.add(createField("📊 Aktueller Wert", String.format("%.2f", currentValue), true)); + } + + if (threshold != null) { + fields.add(createField("⚠️ Grenzwert", String.format("%.2f", threshold), true)); + } + + fields.add(createField("🕐 Zeitpunkt", MessageUtil.getTimestamp(), false)); + + embed.add("fields", fields); + sendEmbed(plugin.getConfigManager().getDiscordWebhookUrl(), embed); + } + + // ────────────────────────────────────────── + // TÄGLICHER REPORT + // ────────────────────────────────────────── + + /** + * Plant den täglichen Status-Report + */ + public void scheduleDailyReport() { + String timeStr = plugin.getConfigManager().getDailyReportTime(); + try { + LocalTime reportTime = LocalTime.parse(timeStr, DateTimeFormatter.ofPattern("HH:mm")); + LocalTime now = LocalTime.now(); + + long secondsUntilReport; + if (now.isBefore(reportTime)) { + secondsUntilReport = now.until(reportTime, java.time.temporal.ChronoUnit.SECONDS); + } else { + secondsUntilReport = now.until(reportTime.plusHours(24), java.time.temporal.ChronoUnit.SECONDS); + } + + long ticksUntilFirst = secondsUntilReport * 20; + long ticksPerDay = 24 * 60 * 60 * 20; + + org.bukkit.Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + this::sendDailyReport, ticksUntilFirst, ticksPerDay); + + plugin.getLogger().info("Täglicher Discord-Report geplant für " + timeStr + " Uhr."); + + } catch (Exception e) { + plugin.getLogger().warning("Ungültige Report-Zeit in Config: " + timeStr); + } + } + + /** + * Erstellt und sendet den täglichen Status-Report + */ + public void sendDailyReport() { + if (!plugin.getConfigManager().isDiscordEnabled()) return; + + JsonObject embed = createEmbed( + "📊 Täglicher Server-Statusbericht", + "Hier ist der automatische Tagesbericht für deinen Minecraft-Server.", + plugin.getConfigManager().getDiscordColorInfo() + ); + + JsonArray fields = new JsonArray(); + + // Performance-Daten + PerformanceSnapshot perf = plugin.getPerformanceMonitor().getLastSnapshot(); + if (perf != null) { + fields.add(createField("⚡ TPS (aktuell)", + String.format("%.2f / 20.0", perf.getTps()), true)); + fields.add(createField("⏱ MSPT", + String.format("%.2f ms", perf.getMspt()), true)); + fields.add(createField("💾 RAM", + perf.getRamUsedMb() + " MB / " + perf.getRamMaxMb() + " MB", true)); + fields.add(createField("👥 Online", + perf.getOnlinePlayers() + " Spieler", true)); + fields.add(createField("🗺 Welten", + perf.getLoadedWorlds() + " geladen", true)); + fields.add(createField("📦 Chunks", + perf.getLoadedChunks() + " geladen", true)); + } + + // Durchschnittliche TPS der letzten 24h + if (plugin.getDatabaseManager().isConnected()) { + double avgTps24h = plugin.getDatabaseManager().getAverageTps(60 * 24); + fields.add(createField("📈 Ø TPS (24h)", + String.format("%.2f", avgTps24h), true)); + + int criticalAlerts = plugin.getDatabaseManager().getAlertCount(24, "CRITICAL"); + int warningAlerts = plugin.getDatabaseManager().getAlertCount(24, "WARNING"); + fields.add(createField("🚨 Kritische Warnungen (24h)", + String.valueOf(criticalAlerts), true)); + fields.add(createField("⚠️ Warnungen (24h)", + String.valueOf(warningAlerts), true)); + } + + // Entity-Daten pro Welt + for (var entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + EntitySnapshot snap = entry.getValue(); + String displayName = plugin.getConfigManager().getWorldDisplayName(entry.getKey()); + fields.add(createField("🌍 " + displayName, + "Gesamt: **" + snap.getTotal() + "** | Monster: " + snap.getMonsters() + + " | Tiere: " + snap.getAnimals() + + " | Villager: " + snap.getVillagers() + + " | Items: " + snap.getItems(), + false)); + } + + fields.add(createField("🕐 Erstellt", MessageUtil.getTimestamp(), false)); + + embed.add("fields", fields); + + // Footer + JsonObject footer = new JsonObject(); + footer.addProperty("text", "ServerPulse Monitoring System"); + embed.add("footer", footer); + + sendEmbed(plugin.getConfigManager().getDiscordReportWebhookUrl(), embed); + } + + /** + * Sendet eine einfache Info-Nachricht + */ + public void sendInfo(String title, String message) { + if (!plugin.getConfigManager().isDiscordEnabled()) return; + + JsonObject embed = createEmbed(title, message, plugin.getConfigManager().getDiscordColorInfo()); + sendEmbed(plugin.getConfigManager().getDiscordWebhookUrl(), embed); + } + + // ────────────────────────────────────────── + // HTTP-VERSAND + // ────────────────────────────────────────── + + /** + * Sendet ein Discord-Embed an einen Webhook + */ + private void sendEmbed(String webhookUrl, JsonObject embed) { + if (webhookUrl == null || webhookUrl.isEmpty() || webhookUrl.contains("YOUR_WEBHOOK")) { + plugin.debug("Discord-Webhook nicht konfiguriert, überspringe."); + return; + } + + JsonObject payload = new JsonObject(); + payload.addProperty("username", plugin.getConfigManager().getDiscordBotName()); + + String avatarUrl = plugin.getConfigManager().getDiscordBotAvatar(); + if (!avatarUrl.isEmpty()) { + payload.addProperty("avatar_url", avatarUrl); + } + + JsonArray embeds = new JsonArray(); + embeds.add(embed); + payload.add("embeds", embeds); + + org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + RequestBody body = RequestBody.create( + payload.toString(), + MediaType.get("application/json") + ); + Request request = new Request.Builder() + .url(webhookUrl) + .post(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + plugin.getLogger().warning("Discord-Webhook Fehler: " + response.code()); + } else { + plugin.debug("Discord-Nachricht erfolgreich gesendet."); + } + } + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Discord-Webhook Verbindungsfehler: " + e.getMessage()); + } + }); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private JsonObject createEmbed(String title, String description, int color) { + JsonObject embed = new JsonObject(); + embed.addProperty("title", title); + embed.addProperty("description", description); + embed.addProperty("color", color); + return embed; + } + + private JsonObject createField(String name, String value, boolean inline) { + JsonObject field = new JsonObject(); + field.addProperty("name", name); + field.addProperty("value", value.isEmpty() ? "N/A" : value); + field.addProperty("inline", inline); + return field; + } + + private String formatAlertType(String alertType) { + return alertType.replace("_", " "); + } +} diff --git a/src/main/java/de/serverpulse/listeners/PlayerListener.java b/src/main/java/de/serverpulse/listeners/PlayerListener.java new file mode 100644 index 0000000..b8532c3 --- /dev/null +++ b/src/main/java/de/serverpulse/listeners/PlayerListener.java @@ -0,0 +1,32 @@ +package de.serverpulse.listeners; + +import de.serverpulse.ServerPulse; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +/** + * Lauscht auf Spieler-Events für Monitoring-Zwecke. + */ +public class PlayerListener implements Listener { + + private final ServerPulse plugin; + + public PlayerListener(ServerPulse plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + plugin.debug("Spieler verbunden: " + event.getPlayer().getName() + + " | Gesamt online: " + (plugin.getServer().getOnlinePlayers().size())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) { + plugin.debug("Spieler getrennt: " + event.getPlayer().getName() + + " | Verbleibend: " + (plugin.getServer().getOnlinePlayers().size() - 1)); + } +} diff --git a/src/main/java/de/serverpulse/models/AlertSeverity.java b/src/main/java/de/serverpulse/models/AlertSeverity.java new file mode 100644 index 0000000..f0698af --- /dev/null +++ b/src/main/java/de/serverpulse/models/AlertSeverity.java @@ -0,0 +1,13 @@ +package de.serverpulse.models; + +public enum AlertSeverity { + INFO("INFO", "&b"), + WARNING("WARNING", "&e"), + CRITICAL("CRITICAL", "&c"); + + private final String name, color; + AlertSeverity(String name, String color) { this.name = name; this.color = color; } + public String getName() { return name; } + public String getColor() { return color; } + @Override public String toString() { return name; } +} diff --git a/src/main/java/de/serverpulse/models/EntitySnapshot.java b/src/main/java/de/serverpulse/models/EntitySnapshot.java new file mode 100644 index 0000000..72181d2 --- /dev/null +++ b/src/main/java/de/serverpulse/models/EntitySnapshot.java @@ -0,0 +1,34 @@ +package de.serverpulse.models; + +import java.time.LocalDateTime; + +public class EntitySnapshot { + private final LocalDateTime recordedAt; + private final String worldName; + private final int monsters, animals, waterMobs, villagers; + private final int armorStands, hopperMinecarts, items, players, other, total; + + public EntitySnapshot(LocalDateTime recordedAt, String worldName, + int monsters, int animals, int waterMobs, int villagers, + int armorStands, int hopperMinecarts, int items, + int players, int other, int total) { + this.recordedAt = recordedAt; this.worldName = worldName; + this.monsters = monsters; this.animals = animals; this.waterMobs = waterMobs; + this.villagers = villagers; this.armorStands = armorStands; + this.hopperMinecarts = hopperMinecarts; this.items = items; + this.players = players; this.other = other; this.total = total; + } + + public LocalDateTime getRecordedAt() { return recordedAt; } + public String getWorldName() { return worldName; } + public int getMonsters() { return monsters; } + public int getAnimals() { return animals; } + public int getWaterMobs() { return waterMobs; } + public int getVillagers() { return villagers; } + public int getArmorStands() { return armorStands; } + public int getHopperMinecarts() { return hopperMinecarts; } + public int getItems() { return items; } + public int getPlayers() { return players; } + public int getOther() { return other; } + public int getTotal() { return total; } +} diff --git a/src/main/java/de/serverpulse/models/PerformanceSnapshot.java b/src/main/java/de/serverpulse/models/PerformanceSnapshot.java new file mode 100644 index 0000000..c100457 --- /dev/null +++ b/src/main/java/de/serverpulse/models/PerformanceSnapshot.java @@ -0,0 +1,28 @@ +package de.serverpulse.models; + +import java.time.LocalDateTime; + +public class PerformanceSnapshot { + private final LocalDateTime recordedAt; + private final double tps, mspt, ramPercent; + private final long ramUsedMb, ramMaxMb; + private final int onlinePlayers, loadedWorlds, loadedChunks; + + public PerformanceSnapshot(LocalDateTime recordedAt, double tps, double mspt, + long ramUsedMb, long ramMaxMb, double ramPercent, + int onlinePlayers, int loadedWorlds, int loadedChunks) { + this.recordedAt = recordedAt; this.tps = tps; this.mspt = mspt; + this.ramUsedMb = ramUsedMb; this.ramMaxMb = ramMaxMb; this.ramPercent = ramPercent; + this.onlinePlayers = onlinePlayers; this.loadedWorlds = loadedWorlds; this.loadedChunks = loadedChunks; + } + + public LocalDateTime getRecordedAt() { return recordedAt; } + public double getTps() { return tps; } + public double getMspt() { return mspt; } + public long getRamUsedMb() { return ramUsedMb; } + public long getRamMaxMb() { return ramMaxMb; } + public double getRamPercent() { return ramPercent; } + public int getOnlinePlayers() { return onlinePlayers; } + public int getLoadedWorlds() { return loadedWorlds; } + public int getLoadedChunks() { return loadedChunks; } +} diff --git a/src/main/java/de/serverpulse/monitoring/EntityMonitor.java b/src/main/java/de/serverpulse/monitoring/EntityMonitor.java new file mode 100644 index 0000000..6b6d312 --- /dev/null +++ b/src/main/java/de/serverpulse/monitoring/EntityMonitor.java @@ -0,0 +1,212 @@ +package de.serverpulse.monitoring; + +import de.serverpulse.ServerPulse; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.*; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.entity.minecart.HopperMinecart; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Erfasst und kategorisiert alle Entitäten pro Welt. + * Kategorien: Monster, Tiere, Wasser-Mobs, Villager, + * ArmorStands, Hopper-Minecarts, Items, Spieler, Sonstige. + */ +public class EntityMonitor { + + private final ServerPulse plugin; + private BukkitTask task; + + // Cache: letzter Snapshot pro Welt + private final Map lastSnapshots = new ConcurrentHashMap<>(); + + public EntityMonitor(ServerPulse plugin) { + this.plugin = plugin; + } + + public void start() { + int intervalSeconds = plugin.getConfigManager().getEntityInterval(); + int intervalTicks = intervalSeconds * 20; + + // Entity-Zählung muss auf dem Haupt-Thread laufen (Bukkit API) + task = Bukkit.getScheduler().runTaskTimer(plugin, this::collect, 200L, intervalTicks); + plugin.getLogger().info("Entity Monitor gestartet (Intervall: " + intervalSeconds + "s)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Entity Monitor gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // DATENERFASSUNG + // ────────────────────────────────────────── + + private void collect() { + for (World world : Bukkit.getWorlds()) { + if (!plugin.getConfigManager().isWorldMonitored(world.getName())) continue; + + EntitySnapshot snapshot = countEntities(world); + lastSnapshots.put(world.getName(), snapshot); + + // Async in DB speichern + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveEntityMetrics(snapshot); + } + checkEntityThresholds(snapshot); + }); + + plugin.debug("Entities in " + world.getName() + ": " + snapshot.getTotal() + " gesamt"); + } + } + + /** + * Zählt und kategorisiert alle Entitäten einer Welt + */ + public EntitySnapshot countEntities(World world) { + int monsters = 0, animals = 0, waterMobs = 0, villagers = 0; + int armorStands = 0, hopperMinecarts = 0, items = 0, players = 0, other = 0; + + for (Entity entity : world.getEntities()) { + if (entity instanceof Monster) { + monsters++; + } else if (entity instanceof WaterMob) { + waterMobs++; + } else if (entity instanceof Villager || entity instanceof WanderingTrader) { + villagers++; + } else if (entity instanceof Animals || entity instanceof Golem) { + animals++; + } else if (entity instanceof ArmorStand) { + armorStands++; + } else if (entity instanceof HopperMinecart) { + hopperMinecarts++; + } else if (entity instanceof Item) { + items++; + } else if (entity instanceof Player) { + players++; + } else { + other++; + } + } + + int total = monsters + animals + waterMobs + villagers + + armorStands + hopperMinecarts + items + players + other; + + return new EntitySnapshot( + LocalDateTime.now(), world.getName(), + monsters, animals, waterMobs, villagers, + armorStands, hopperMinecarts, items, players, other, total + ); + } + + // ────────────────────────────────────────── + // GRENZWERT-PRÜFUNG + // ────────────────────────────────────────── + + private void checkEntityThresholds(EntitySnapshot snapshot) { + String world = snapshot.getWorldName(); + + // Gesamt-Entities + int totalWarning = plugin.getConfigManager().getWorldTotalWarning(world); + int totalCritical = plugin.getConfigManager().getWorldTotalCritical(world); + + if (snapshot.getTotal() >= totalCritical) { + plugin.getAlertManager().triggerAlert( + "ENTITY_TOTAL_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch hohe Entity-Anzahl in " + world + ": " + snapshot.getTotal(), + (double) snapshot.getTotal(), (double) totalCritical + ); + } else if (snapshot.getTotal() >= totalWarning) { + plugin.getAlertManager().triggerAlert( + "ENTITY_TOTAL_WARNING", AlertSeverity.WARNING, world, + "Hohe Entity-Anzahl in " + world + ": " + snapshot.getTotal(), + (double) snapshot.getTotal(), (double) totalWarning + ); + } + + // Monster + int monstersWarning = plugin.getConfigManager().getWorldMonstersWarning(world); + int monstersCritical = plugin.getConfigManager().getWorldMonstersCritical(world); + + if (snapshot.getMonsters() >= monstersCritical) { + plugin.getAlertManager().triggerAlert( + "MONSTERS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Monster in " + world + ": " + snapshot.getMonsters(), + (double) snapshot.getMonsters(), (double) monstersCritical + ); + } else if (snapshot.getMonsters() >= monstersWarning) { + plugin.getAlertManager().triggerAlert( + "MONSTERS_WARNING", AlertSeverity.WARNING, world, + "Viele Monster in " + world + ": " + snapshot.getMonsters(), + (double) snapshot.getMonsters(), (double) monstersWarning + ); + } + + // Items + int itemsWarning = plugin.getConfigManager().getItemsWarning(); + int itemsCritical = plugin.getConfigManager().getItemsCritical(); + + if (snapshot.getItems() >= itemsCritical) { + plugin.getAlertManager().triggerAlert( + "ITEMS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Items in " + world + ": " + snapshot.getItems(), + (double) snapshot.getItems(), (double) itemsCritical + ); + // Notfall-Item-Clear auslösen + if (plugin.getConfigManager().isItemClearEnabled()) { + plugin.getAlertManager().triggerItemClear(world); + } + } else if (snapshot.getItems() >= itemsWarning) { + plugin.getAlertManager().triggerAlert( + "ITEMS_WARNING", AlertSeverity.WARNING, world, + "Viele Items in " + world + ": " + snapshot.getItems(), + (double) snapshot.getItems(), (double) itemsWarning + ); + } + + // Villager + int villagersWarning = plugin.getConfigManager().getVillagersWarning(); + int villagersCritical = plugin.getConfigManager().getVillagersCritical(); + + if (snapshot.getVillagers() >= villagersCritical) { + plugin.getAlertManager().triggerAlert( + "VILLAGERS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Villager in " + world + ": " + snapshot.getVillagers(), + (double) snapshot.getVillagers(), (double) villagersCritical + ); + } else if (snapshot.getVillagers() >= villagersWarning) { + plugin.getAlertManager().triggerAlert( + "VILLAGERS_WARNING", AlertSeverity.WARNING, world, + "Viele Villager in " + world + ": " + snapshot.getVillagers(), + (double) snapshot.getVillagers(), (double) villagersWarning + ); + } + } + + // ────────────────────────────────────────── + // GETTER + // ────────────────────────────────────────── + + public Map getAllLastSnapshots() { + return new HashMap<>(lastSnapshots); + } + + public EntitySnapshot getLastSnapshot(String worldName) { + return lastSnapshots.get(worldName); + } +} diff --git a/src/main/java/de/serverpulse/monitoring/PerformanceMonitor.java b/src/main/java/de/serverpulse/monitoring/PerformanceMonitor.java new file mode 100644 index 0000000..06c92d6 --- /dev/null +++ b/src/main/java/de/serverpulse/monitoring/PerformanceMonitor.java @@ -0,0 +1,213 @@ +package de.serverpulse.monitoring; + +import de.serverpulse.ServerPulse; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.PerformanceSnapshot; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; + +import java.time.LocalDateTime; + +/** + * Erfasst Server-Performance-Daten: TPS, MSPT, RAM, Spieler, Chunks. + * Läuft als wiederkehrender Bukkit-Task. + */ +public class PerformanceMonitor { + + private final ServerPulse plugin; + private BukkitTask task; + + // Letzter bekannter Snapshot (für Status-Befehle) + private PerformanceSnapshot lastSnapshot; + + public PerformanceMonitor(ServerPulse plugin) { + this.plugin = plugin; + } + + public void start() { + int intervalSeconds = plugin.getConfigManager().getMetricsInterval(); + int intervalTicks = intervalSeconds * 20; + + task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::collect, 100L, intervalTicks); + plugin.getLogger().info("Performance Monitor gestartet (Intervall: " + intervalSeconds + "s)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Performance Monitor gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // DATENERFASSUNG + // ────────────────────────────────────────── + + private void collect() { + try { + double tps = getTps(); + double mspt = getMspt(); + long ramUsed = getRamUsedMb(); + long ramMax = getRamMaxMb(); + double ramPercent = (double) ramUsed / ramMax * 100; + int onlinePlayers = Bukkit.getOnlinePlayers().size(); + int loadedWorlds = Bukkit.getWorlds().size(); + int loadedChunks = getTotalLoadedChunks(); + + PerformanceSnapshot snapshot = new PerformanceSnapshot( + LocalDateTime.now(), tps, mspt, + ramUsed, ramMax, ramPercent, + onlinePlayers, loadedWorlds, loadedChunks + ); + + this.lastSnapshot = snapshot; + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveServerMetrics(snapshot); + } + + // Grenzwerte prüfen + checkThresholds(snapshot); + + plugin.debug("Performance erfasst: TPS=" + String.format("%.2f", tps) + + " MSPT=" + String.format("%.2f", mspt) + + " RAM=" + ramUsed + "MB/" + ramMax + "MB"); + + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Erfassen der Performance: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // GRENZWERT-PRÜFUNG + // ────────────────────────────────────────── + + private void checkThresholds(PerformanceSnapshot snapshot) { + double tps = snapshot.getTps(); + double mspt = snapshot.getMspt(); + double ramPercent = snapshot.getRamPercent(); + + // TPS prüfen + if (tps < plugin.getConfigManager().getTpsCritical()) { + plugin.getAlertManager().triggerAlert( + "TPS_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritisch niedrige TPS: " + String.format("%.2f", tps), + tps, plugin.getConfigManager().getTpsCritical() + ); + } else if (tps < plugin.getConfigManager().getTpsWarning()) { + plugin.getAlertManager().triggerAlert( + "TPS_WARNING", AlertSeverity.WARNING, null, + "Niedrige TPS: " + String.format("%.2f", tps), + tps, plugin.getConfigManager().getTpsWarning() + ); + } + + // MSPT prüfen + if (mspt > plugin.getConfigManager().getMsptCritical()) { + plugin.getAlertManager().triggerAlert( + "MSPT_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritisch hohe MSPT: " + String.format("%.2f", mspt) + "ms", + mspt, plugin.getConfigManager().getMsptCritical() + ); + } else if (mspt > plugin.getConfigManager().getMsptWarning()) { + plugin.getAlertManager().triggerAlert( + "MSPT_WARNING", AlertSeverity.WARNING, null, + "Hohe MSPT: " + String.format("%.2f", mspt) + "ms", + mspt, plugin.getConfigManager().getMsptWarning() + ); + } + + // RAM prüfen + if (ramPercent > plugin.getConfigManager().getRamCritical()) { + plugin.getAlertManager().triggerAlert( + "RAM_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritische RAM-Auslastung: " + String.format("%.1f", ramPercent) + "%", + ramPercent, (double) plugin.getConfigManager().getRamCritical() + ); + } else if (ramPercent > plugin.getConfigManager().getRamWarning()) { + plugin.getAlertManager().triggerAlert( + "RAM_WARNING", AlertSeverity.WARNING, null, + "Hohe RAM-Auslastung: " + String.format("%.1f", ramPercent) + "%", + ramPercent, (double) plugin.getConfigManager().getRamWarning() + ); + } + } + + // ────────────────────────────────────────── + // TPS & MSPT ABRUFEN + // ────────────────────────────────────────── + + /** + * Liest den aktuellen TPS-Wert vom Server. + * Paper API bietet getTPS(), Spigot hat recentTps[]. + */ + public double getTps() { + try { + // Paper API: getServer().getTPS() + double[] tps = Bukkit.getServer().getTPS(); + return Math.min(20.0, tps[0]); // 1-Minuten-Durchschnitt + } catch (Exception e) { + // Fallback für Spigot + return 20.0; + } + } + + /** + * Liest den aktuellen MSPT-Wert. + * Paper: getAverageTickTime() + */ + public double getMspt() { + try { + return Bukkit.getServer().getAverageTickTime(); + } catch (Exception e) { + // Berechnung aus TPS als Fallback + double tps = getTps(); + return tps > 0 ? 1000.0 / tps : 50.0; + } + } + + // ────────────────────────────────────────── + // RAM + // ────────────────────────────────────────── + + public long getRamUsedMb() { + Runtime runtime = Runtime.getRuntime(); + return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024); + } + + public long getRamMaxMb() { + return Runtime.getRuntime().maxMemory() / (1024 * 1024); + } + + public long getRamFreeMb() { + Runtime runtime = Runtime.getRuntime(); + return (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / (1024 * 1024); + } + + // ────────────────────────────────────────── + // CHUNKS + // ────────────────────────────────────────── + + private int getTotalLoadedChunks() { + int total = 0; + for (World world : Bukkit.getWorlds()) { + total += world.getLoadedChunks().length; + } + return total; + } + + // ────────────────────────────────────────── + // GETTER + // ────────────────────────────────────────── + + public PerformanceSnapshot getLastSnapshot() { + return lastSnapshot; + } +} diff --git a/src/main/java/de/serverpulse/monitoring/TrendAnalyzer.java b/src/main/java/de/serverpulse/monitoring/TrendAnalyzer.java new file mode 100644 index 0000000..54aad52 --- /dev/null +++ b/src/main/java/de/serverpulse/monitoring/TrendAnalyzer.java @@ -0,0 +1,200 @@ +package de.serverpulse.monitoring; + +import de.serverpulse.ServerPulse; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; + +import java.util.List; + +/** + * Analysiert Trends in den gesammelten Daten. + * Erkennt kontinuierlich steigende Werte wie Monster-Anzahl, + * MSPT-Werte oder RAM-Auslastung. + */ +public class TrendAnalyzer { + + private final ServerPulse plugin; + private BukkitTask task; + + public TrendAnalyzer(ServerPulse plugin) { + this.plugin = plugin; + } + + public void start() { + if (!plugin.getConfigManager().isTrendAnalysisEnabled()) { + plugin.getLogger().info("Trend-Analyse ist deaktiviert."); + return; + } + + int intervalMinutes = plugin.getConfigManager().getTrendCheckInterval(); + long intervalTicks = intervalMinutes * 60L * 20L; + + task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::analyze, 600L, intervalTicks); + plugin.getLogger().info("Trend Analyzer gestartet (Intervall: " + intervalMinutes + " Minuten)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Trend Analyzer gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // ANALYSE + // ────────────────────────────────────────── + + private void analyze() { + if (!plugin.getDatabaseManager().isConnected()) return; + + // TPS-Trend analysieren + analyzeTpsTrend(); + + // Entity-Trends analysieren (pro Welt) + for (org.bukkit.World world : Bukkit.getWorlds()) { + if (!plugin.getConfigManager().isWorldMonitored(world.getName())) continue; + analyzeEntityTrend(world.getName()); + } + + plugin.debug("Trend-Analyse abgeschlossen."); + } + + /** + * Analysiert den TPS-Trend der letzten Datenpunkte + */ + private void analyzeTpsTrend() { + int dataPoints = plugin.getConfigManager().getTrendDataPoints(); + List snapshots = + plugin.getDatabaseManager().getRecentServerMetrics(dataPoints); + + if (snapshots.size() < 3) return; + + // Prüfen ob TPS kontinuierlich fällt + boolean continuousDecline = true; + for (int i = 0; i < snapshots.size() - 1; i++) { + if (snapshots.get(i).getTps() >= snapshots.get(i + 1).getTps()) { + continuousDecline = false; + break; + } + } + + if (continuousDecline) { + double startTps = snapshots.get(snapshots.size() - 1).getTps(); + double endTps = snapshots.get(0).getTps(); + double decline = ((startTps - endTps) / startTps) * 100; + + if (decline >= plugin.getConfigManager().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "TPS_TREND_DECLINE", AlertSeverity.WARNING, null, + String.format("TPS-Abnahme-Trend erkannt: %.1f%% Rückgang über %d Messungen", + decline, dataPoints), + endTps, startTps + ); + } + } + + // Prüfen ob MSPT kontinuierlich steigt + boolean continuousMsptIncrease = true; + for (int i = 1; i < snapshots.size(); i++) { + if (snapshots.get(i).getMspt() <= snapshots.get(i - 1).getMspt()) { + continuousMsptIncrease = false; + break; + } + } + + if (continuousMsptIncrease && snapshots.size() >= 3) { + double startMspt = snapshots.get(snapshots.size() - 1).getMspt(); + double endMspt = snapshots.get(0).getMspt(); + double increase = ((endMspt - startMspt) / startMspt) * 100; + + if (increase >= plugin.getConfigManager().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "MSPT_TREND_INCREASE", AlertSeverity.WARNING, null, + String.format("MSPT-Anstiegs-Trend erkannt: %.1f%% Anstieg über %d Messungen", + increase, dataPoints), + endMspt, startMspt + ); + } + } + } + + /** + * Analysiert Entity-Trends für eine spezifische Welt + */ + private void analyzeEntityTrend(String worldName) { + int dataPoints = plugin.getConfigManager().getTrendDataPoints(); + List snapshots = + plugin.getDatabaseManager().getEntityHistory(worldName, dataPoints); + + if (snapshots.size() < 3) return; + + // Monster-Trend analysieren + boolean monsterIncrease = checkIncreasingTrend( + snapshots.stream().mapToDouble(EntitySnapshot::getMonsters).toArray() + ); + + if (monsterIncrease) { + double startMonsters = snapshots.get(snapshots.size() - 1).getMonsters(); + double endMonsters = snapshots.get(0).getMonsters(); + + if (startMonsters > 0) { + double increase = ((endMonsters - startMonsters) / startMonsters) * 100; + + if (increase >= plugin.getConfigManager().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "MONSTER_TREND_INCREASE", AlertSeverity.WARNING, worldName, + String.format("Monster-Anstiegs-Trend in %s: %.1f%% Anstieg (%d → %d)", + worldName, increase, (int) startMonsters, (int) endMonsters), + endMonsters, startMonsters + ); + } + } + } + + // Item-Trend analysieren + boolean itemIncrease = checkIncreasingTrend( + snapshots.stream().mapToDouble(EntitySnapshot::getItems).toArray() + ); + + if (itemIncrease) { + double startItems = snapshots.get(snapshots.size() - 1).getItems(); + double endItems = snapshots.get(0).getItems(); + + if (startItems > 0) { + double increase = ((endItems - startItems) / startItems) * 100; + + if (increase >= plugin.getConfigManager().getTrendIncreaseThreshold() * 1.5) { + plugin.getAlertManager().triggerAlert( + "ITEMS_TREND_INCREASE", AlertSeverity.WARNING, worldName, + String.format("Item-Anstiegs-Trend in %s: %.1f%% Anstieg (%d → %d)", + worldName, increase, (int) startItems, (int) endItems), + endItems, startItems + ); + } + } + } + } + + /** + * Prüft ob ein Array von Werten kontinuierlich steigt + * (erlaubt kleine Rückgänge) + */ + private boolean checkIncreasingTrend(double[] values) { + if (values.length < 3) return false; + + int increasingCount = 0; + for (int i = 0; i < values.length - 1; i++) { + if (values[i] > values[i + 1]) increasingCount++; + } + + // Mindestens 70% der Messungen müssen steigen + return (double) increasingCount / (values.length - 1) >= 0.7; + } +} diff --git a/src/main/java/de/serverpulse/network/NetworkMessage.java b/src/main/java/de/serverpulse/network/NetworkMessage.java new file mode 100644 index 0000000..e8b733a --- /dev/null +++ b/src/main/java/de/serverpulse/network/NetworkMessage.java @@ -0,0 +1,21 @@ +package de.serverpulse.network; + +/** + * Konstanten für das Plugin-Messaging-Protokoll zwischen Spigot und BungeeCord. + * Kanal: "serverpulse:data" + * + * Alle Nachrichten werden als UTF-8 JSON-String übertragen. + */ +public final class NetworkMessage { + + public static final String CHANNEL = "serverpulse:data"; + + // Nachrichtentypen (Feld "type" im JSON) + public static final String TYPE_PERFORMANCE = "PERFORMANCE"; + public static final String TYPE_ENTITY = "ENTITY"; + public static final String TYPE_ALERT = "ALERT"; + public static final String TYPE_CHAT_EVENT = "CHAT_EVENT"; + public static final String TYPE_HEARTBEAT = "HEARTBEAT"; + + private NetworkMessage() {} +} diff --git a/src/main/java/de/serverpulse/spigot/SpigotPlugin.java b/src/main/java/de/serverpulse/spigot/SpigotPlugin.java new file mode 100644 index 0000000..478761d --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/SpigotPlugin.java @@ -0,0 +1,103 @@ +package de.serverpulse.spigot; + +import de.serverpulse.spigot.alerts.AlertManager; +import de.serverpulse.spigot.commands.SpigotCommand; +import de.serverpulse.spigot.database.DatabaseManager; +import de.serverpulse.spigot.discord.SpigotDiscordWebhook; +import de.serverpulse.spigot.listeners.SpigotPlayerListener; +import de.serverpulse.spigot.monitoring.EntityMonitor; +import de.serverpulse.spigot.monitoring.PerformanceMonitor; +import de.serverpulse.spigot.monitoring.TrendAnalyzer; +import de.serverpulse.spigot.network.SpigotMessenger; +import de.serverpulse.spigot.utils.SpigotConfig; +import org.bukkit.plugin.java.JavaPlugin; + +public final class SpigotPlugin extends JavaPlugin { + + private static SpigotPlugin instance; + private SpigotConfig config; + private DatabaseManager databaseManager; + private PerformanceMonitor performanceMonitor; + private EntityMonitor entityMonitor; + private TrendAnalyzer trendAnalyzer; + private AlertManager alertManager; + private SpigotDiscordWebhook discordWebhook; + private SpigotMessenger messenger; + + @Override + public void onEnable() { + instance = this; + getLogger().info("╔══════════════════════════════════════╗"); + getLogger().info("║ ServerPulse v1.2.0 [SPIGOT/PAPER] ║"); + getLogger().info("╚══════════════════════════════════════╝"); + + this.config = new SpigotConfig(this); + config.load(); + + this.databaseManager = new DatabaseManager(this); + if (config.isDatabaseEnabled()) { + if (!databaseManager.connect()) { + getLogger().severe("Datenbankverbindung fehlgeschlagen! Plugin wird deaktiviert."); + getServer().getPluginManager().disablePlugin(this); + return; + } + databaseManager.createTables(); + } + + this.discordWebhook = new SpigotDiscordWebhook(this); + this.alertManager = new AlertManager(this); + + this.performanceMonitor = new PerformanceMonitor(this); + performanceMonitor.start(); + + this.entityMonitor = new EntityMonitor(this); + entityMonitor.start(); + + this.trendAnalyzer = new TrendAnalyzer(this); + trendAnalyzer.start(); + + this.messenger = new SpigotMessenger(this); + messenger.start(); + + SpigotCommand cmd = new SpigotCommand(this); + getCommand("serverpulse").setExecutor(cmd); + getCommand("serverpulse").setTabCompleter(cmd); + + getServer().getPluginManager().registerEvents(new SpigotPlayerListener(this), this); + + if (config.isDailyReportEnabled()) discordWebhook.scheduleDailyReport(); + + getLogger().info("ServerPulse v" + getDescription().getVersion() + " (Spigot/Paper) gestartet!"); + } + + @Override + public void onDisable() { + if (messenger != null) messenger.stop(); + if (performanceMonitor != null) performanceMonitor.stop(); + if (entityMonitor != null) entityMonitor.stop(); + if (trendAnalyzer != null) trendAnalyzer.stop(); + if (databaseManager != null) databaseManager.disconnect(); + } + + public void reload() { + config.load(); + if (performanceMonitor != null) performanceMonitor.restart(); + if (entityMonitor != null) entityMonitor.restart(); + if (trendAnalyzer != null) trendAnalyzer.restart(); + alertManager.clearCooldowns(); + } + + public void debug(String msg) { + if (config != null && config.isDebugMode()) getLogger().info("[DEBUG] " + msg); + } + + public static SpigotPlugin getInstance() { return instance; } + public SpigotConfig getSpigotConfig() { return config; } + public DatabaseManager getDatabaseManager() { return databaseManager; } + public PerformanceMonitor getPerformanceMonitor() { return performanceMonitor; } + public EntityMonitor getEntityMonitor() { return entityMonitor; } + public TrendAnalyzer getTrendAnalyzer() { return trendAnalyzer; } + public AlertManager getAlertManager() { return alertManager; } + public SpigotDiscordWebhook getDiscordWebhook() { return discordWebhook; } + public SpigotMessenger getMessenger() { return messenger; } +} diff --git a/src/main/java/de/serverpulse/spigot/alerts/AlertManager.java b/src/main/java/de/serverpulse/spigot/alerts/AlertManager.java new file mode 100644 index 0000000..f13d46c --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/alerts/AlertManager.java @@ -0,0 +1,219 @@ +package de.serverpulse.spigot.alerts; + +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.spigot.utils.MsgUtil; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.entity.Monster; + +import java.util.HashMap; +import java.util.Map; + +/** + * Verwaltet alle Warnungen, Cooldowns und Notfallmaßnahmen. + * Verhindert Alert-Spam durch einen Cooldown-Mechanismus. + */ +public class AlertManager { + + private final SpigotPlugin plugin; + + // Cooldown: alertType -> letzter Trigger-Zeitpunkt (ms) + private final Map alertCooldowns = new HashMap<>(); + // Standard-Cooldown: 5 Minuten + private static final long DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; + + // Zähler für kritische Ereignisse (für Auto-Diagnose) + private int criticalEventCount = 0; + + // Item-Clear läuft gerade? + private boolean itemClearRunning = false; + + public AlertManager(SpigotPlugin plugin) { + this.plugin = plugin; + } + + // ────────────────────────────────────────── + // ALERT AUSLÖSEN + // ────────────────────────────────────────── + + /** + * Löst eine Warnung aus, wenn kein Cooldown aktiv ist. + */ + public void triggerAlert(String alertType, AlertSeverity severity, String worldName, + String message, Double currentValue, Double threshold) { + // Cooldown prüfen + String cooldownKey = alertType + (worldName != null ? "_" + worldName : ""); + long now = System.currentTimeMillis(); + + if (alertCooldowns.containsKey(cooldownKey)) { + long lastAlert = alertCooldowns.get(cooldownKey); + if (now - lastAlert < DEFAULT_COOLDOWN_MS) { + plugin.debug("Alert-Cooldown aktiv für: " + cooldownKey); + return; + } + } + + alertCooldowns.put(cooldownKey, now); + + // Alert loggen + plugin.getLogger().warning("[" + severity.getName() + "] " + message); + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveAlert( + alertType, severity.getName(), worldName, message, currentValue, threshold + ); + } + + // Discord-Benachrichtigung senden + if (plugin.getSpigotConfig().isDiscordEnabled()) { + plugin.getDiscordWebhook().sendAlert(alertType, severity, worldName, message, currentValue, threshold); + } + + // Alert an BungeeCord weitergeben + if (plugin.getMessenger() != null) { + plugin.getMessenger().sendAlert(alertType, severity.getName(), message); + } + + // Kritische Events zählen für Auto-Diagnose + if (severity == AlertSeverity.CRITICAL) { + criticalEventCount++; + if (plugin.getSpigotConfig().isAutoDiagnosisEnabled()) { + if (criticalEventCount >= plugin.getSpigotConfig().getAutoDiagnosisTrigger()) { + triggerDiagnosisReport(); + criticalEventCount = 0; + } + } + } + } + + // ────────────────────────────────────────── + // NOTFALLMASSNAHMEN + // ────────────────────────────────────────── + + /** + * Löst einen Item-Clear mit Countdown aus + */ + public void triggerItemClear(String worldName) { + if (itemClearRunning) return; + itemClearRunning = true; + + int countdown = plugin.getSpigotConfig().getItemClearCountdown(); + String broadcastMsg = plugin.getSpigotConfig().getItemClearMessage(); + + // Countdown-Broadcasts + if (plugin.getSpigotConfig().isItemClearBroadcast()) { + for (int i = countdown; i > 0; i -= (i > 10 ? 10 : (i > 5 ? 5 : 1))) { + final int secondsLeft = i; + Bukkit.getScheduler().runTaskLater(plugin, () -> { + String msg = broadcastMsg.replace("{seconds}", String.valueOf(secondsLeft)); + Bukkit.broadcastMessage(MsgUtil.colorize(msg)); + }, (countdown - i) * 20L); + } + } + + // Clear ausführen nach Countdown + Bukkit.getScheduler().runTaskLater(plugin, () -> { + World world = Bukkit.getWorld(worldName); + if (world != null) { + int cleared = 0; + for (Entity entity : world.getEntities()) { + if (entity instanceof Item) { + entity.remove(); + cleared++; + } + } + plugin.getLogger().info("Item-Clear ausgeführt in " + worldName + ": " + cleared + " Items entfernt."); + if (plugin.getSpigotConfig().isItemClearBroadcast()) { + Bukkit.broadcastMessage(MsgUtil.colorize( + "&a[ServerPulse] Item-Clear abgeschlossen! " + cleared + " Items wurden entfernt.")); + } + triggerAlert("ITEM_CLEAR_EXECUTED", AlertSeverity.INFO, worldName, + "Automatischer Item-Clear: " + cleared + " Items in " + worldName + " entfernt.", + (double) cleared, null); + } + itemClearRunning = false; + }, countdown * 20L); + } + + /** + * Führt einen Mob-Clear in einer Welt durch + */ + public void triggerMobClear(String worldName) { + boolean hostileOnly = plugin.getSpigotConfig().isMobClearHostileOnly(); + World world = Bukkit.getWorld(worldName); + if (world == null) return; + + Bukkit.getScheduler().runTask(plugin, () -> { + int cleared = 0; + for (Entity entity : world.getEntities()) { + if (entity instanceof Monster) { + entity.remove(); + cleared++; + } + } + + plugin.getLogger().info("Mob-Clear ausgeführt in " + worldName + ": " + cleared + " Mobs entfernt."); + triggerAlert("MOB_CLEAR_EXECUTED", AlertSeverity.INFO, worldName, + "Automatischer Mob-Clear: " + cleared + " Mobs in " + worldName + " entfernt.", + (double) cleared, null); + }); + } + + /** + * Erstellt automatisch einen Diagnose-Report + */ + private void triggerDiagnosisReport() { + plugin.getLogger().warning("[ServerPulse] Erstelle automatischen Diagnose-Report..."); + + StringBuilder report = new StringBuilder(); + report.append("=== AUTOMATISCHER DIAGNOSE-REPORT ===\n"); + report.append("Zeitpunkt: ").append(MsgUtil.timestamp()).append("\n\n"); + + // Performance + if (plugin.getPerformanceMonitor().getLastSnapshot() != null) { + var snap = plugin.getPerformanceMonitor().getLastSnapshot(); + report.append("Performance:\n"); + report.append(" TPS: ").append(String.format("%.2f", snap.getTps())).append("\n"); + report.append(" MSPT: ").append(String.format("%.2f", snap.getMspt())).append("ms\n"); + report.append(" RAM: ").append(snap.getRamUsedMb()).append("MB / ").append(snap.getRamMaxMb()).append("MB\n"); + report.append(" Spieler: ").append(snap.getOnlinePlayers()).append("\n"); + report.append(" Chunks: ").append(snap.getLoadedChunks()).append("\n\n"); + } + + // Entities pro Welt + report.append("Entities pro Welt:\n"); + for (var entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + var snap = entry.getValue(); + report.append(" ").append(entry.getKey()).append(": ") + .append("Gesamt=").append(snap.getTotal()) + .append(", Monster=").append(snap.getMonsters()) + .append(", Items=").append(snap.getItems()) + .append("\n"); + } + + String reportContent = report.toString(); + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveReport("AUTO_DIAGNOSIS", "ServerPulse", reportContent); + } + + plugin.getLogger().warning(reportContent); + } + + /** + * Setzt alle Alert-Cooldowns zurück (z.B. nach reload) + */ + public void clearCooldowns() { + alertCooldowns.clear(); + criticalEventCount = 0; + } + + public int getCriticalEventCount() { + return criticalEventCount; + } +} diff --git a/src/main/java/de/serverpulse/spigot/commands/SpigotCommand.java b/src/main/java/de/serverpulse/spigot/commands/SpigotCommand.java new file mode 100644 index 0000000..3ca6d36 --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/commands/SpigotCommand.java @@ -0,0 +1,429 @@ +package de.serverpulse.spigot.commands; + +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; +import de.serverpulse.spigot.utils.MsgUtil; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Verarbeitet alle /serverpulse (Alias: /sp, /pulse) Unterbefehle. + */ +public class SpigotCommand implements CommandExecutor, TabCompleter { + + private final SpigotPlugin plugin; + + public SpigotCommand(SpigotPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + String prefix = plugin.getSpigotConfig().getPrefix(); + + if (args.length == 0) { + sendHelp(sender, prefix, label); + return true; + } + + switch (args[0].toLowerCase()) { + case "status" -> handleStatus(sender, prefix); + case "report" -> handleReport(sender, prefix); + case "world" -> handleWorld(sender, prefix, args); + case "entities" -> handleEntities(sender, prefix, args); + case "reload" -> handleReload(sender, prefix); + case "debug" -> handleDebug(sender, prefix); + case "clear" -> handleClear(sender, prefix, args); + default -> sendHelp(sender, prefix, label); + } + + return true; + } + + // ────────────────────────────────────────── + // /sp status + // ────────────────────────────────────────── + + private void handleStatus(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.status")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("ServerPulse Status"))); + + PerformanceSnapshot snap = plugin.getPerformanceMonitor().getLastSnapshot(); + if (snap == null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&eNoch keine Daten verfügbar. Bitte warte einen Moment...")); + return; + } + + double tps = snap.getTps(); + double mspt = snap.getMspt(); + long ramUsed = snap.getRamUsedMb(); + long ramMax = snap.getRamMaxMb(); + + sender.sendMessage(MsgUtil.colorize( + prefix + "&7TPS: " + MsgUtil.formatTps(tps) + " &8| &7MSPT: " + MsgUtil.formatMspt(mspt) + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7RAM: " + MsgUtil.formatRam(ramUsed, ramMax) + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7RAM-Auslastung: " + MsgUtil.bar(ramUsed, ramMax, 20) + + " &7" + ramUsed + "/" + ramMax + "MB" + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Spieler: &f" + snap.getOnlinePlayers() + + " &8| &7Welten: &f" + snap.getLoadedWorlds() + + " &8| &7Chunks: &f" + snap.getLoadedChunks() + )); + + if (plugin.getDatabaseManager().isConnected()) { + double avgTps15m = plugin.getDatabaseManager().getAverageTps(15); + double avgTps60m = plugin.getDatabaseManager().getAverageTps(60); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Ø TPS: &715min=&f" + String.format("%.2f", avgTps15m) + + " &860min=&f" + String.format("%.2f", avgTps60m) + )); + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp report + // ────────────────────────────────────────── + + private void handleReport(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.report")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + sender.sendMessage(MsgUtil.colorize(prefix + "&aErstelle Report...")); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("ServerPulse Report"))); + + // Performance + PerformanceSnapshot perf = plugin.getPerformanceMonitor().getLastSnapshot(); + if (perf != null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&b=== Performance ===")); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7TPS: " + MsgUtil.formatTps(perf.getTps()) + + " MSPT: " + MsgUtil.formatMspt(perf.getMspt()) + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7RAM: " + MsgUtil.formatRam(perf.getRamUsedMb(), perf.getRamMaxMb()) + )); + } + + // Datenbank-Statistiken + if (plugin.getDatabaseManager().isConnected()) { + sender.sendMessage(MsgUtil.colorize(prefix + "&b=== Warnungen (24h) ===")); + int critAlerts = plugin.getDatabaseManager().getAlertCount(24, "CRITICAL"); + int warnAlerts = plugin.getDatabaseManager().getAlertCount(24, "WARNING"); + sender.sendMessage(MsgUtil.colorize( + prefix + "&c" + critAlerts + " kritisch &e" + warnAlerts + " Warnungen" + )); + } + + // Entities + sender.sendMessage(MsgUtil.colorize(prefix + "&b=== Entities ===")); + for (Map.Entry entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + EntitySnapshot snap = entry.getValue(); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7" + entry.getKey() + ": &fGesamt=" + snap.getTotal() + + " Monster=" + MsgUtil.formatCount( + snap.getMonsters(), + plugin.getSpigotConfig().getWorldMonstersWarning(entry.getKey()), + plugin.getSpigotConfig().getWorldMonstersCritical(entry.getKey())) + )); + } + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveReport("MANUAL", sender.getName(), "Manueller Report erstellt."); + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + }); + } + + // ────────────────────────────────────────── + // /sp world + // ────────────────────────────────────────── + + private void handleWorld(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.world")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + if (args.length < 2) { + sender.sendMessage(MsgUtil.colorize(prefix + "&cBenutzung: /sp world ")); + sender.sendMessage(MsgUtil.colorize(prefix + "&7Verfügbare Welten: &f" + + Bukkit.getWorlds().stream() + .map(World::getName) + .collect(Collectors.joining(", ")))); + return; + } + + String worldName = args[1]; + World world = Bukkit.getWorld(worldName); + + if (world == null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&cWelt nicht gefunden: &f" + worldName)); + return; + } + + String displayName = plugin.getSpigotConfig().getWorldDisplayName(worldName); + + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("Welt: " + displayName))); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Typ: &f" + world.getEnvironment().name() + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Geladene Chunks: &f" + world.getLoadedChunks().length + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Spieler: &f" + world.getPlayers().size() + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Entities gesamt: &f" + world.getEntities().size() + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Schwierigkeitsgrad: &f" + world.getDifficulty().name() + )); + + EntitySnapshot snap = plugin.getEntityMonitor().getLastSnapshot(worldName); + if (snap != null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&b--- Letzter Entity-Snapshot ---")); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Monster: " + MsgUtil.formatCount( + snap.getMonsters(), + plugin.getSpigotConfig().getWorldMonstersWarning(worldName), + plugin.getSpigotConfig().getWorldMonstersCritical(worldName)) + + " Tiere: &a" + snap.getAnimals() + + " Villager: " + MsgUtil.formatCount( + snap.getVillagers(), + plugin.getSpigotConfig().getVillagersWarning(), + plugin.getSpigotConfig().getVillagersCritical()) + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Items: " + MsgUtil.formatCount( + snap.getItems(), + plugin.getSpigotConfig().getItemsWarning(), + plugin.getSpigotConfig().getItemsCritical()) + + " ArmorStands: &7" + snap.getArmorStands() + + " HopperCarts: &7" + snap.getHopperMinecarts() + )); + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp entities [welt] + // ────────────────────────────────────────── + + private void handleEntities(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.entities")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("Entity-Übersicht"))); + + Map snapshots; + if (args.length >= 2) { + String worldName = args[1]; + EntitySnapshot snap = plugin.getEntityMonitor().getLastSnapshot(worldName); + if (snap == null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&cKeine Daten für Welt: &f" + worldName)); + return; + } + snapshots = Map.of(worldName, snap); + } else { + snapshots = plugin.getEntityMonitor().getAllLastSnapshots(); + } + + if (snapshots.isEmpty()) { + sender.sendMessage(MsgUtil.colorize(prefix + "&eNoch keine Entity-Daten verfügbar.")); + return; + } + + for (Map.Entry entry : snapshots.entrySet()) { + EntitySnapshot snap = entry.getValue(); + String wName = entry.getKey(); + String displayName = plugin.getSpigotConfig().getWorldDisplayName(wName); + + sender.sendMessage(MsgUtil.colorize( + prefix + "&b" + displayName + " &8(" + wName + ")" + )); + sender.sendMessage(MsgUtil.colorize( + " &7Monster: " + MsgUtil.formatCount( + snap.getMonsters(), + plugin.getSpigotConfig().getWorldMonstersWarning(wName), + plugin.getSpigotConfig().getWorldMonstersCritical(wName)) + + " &7Tiere: &a" + snap.getAnimals() + + " &7Wasser: &a" + snap.getWaterMobs() + )); + sender.sendMessage(MsgUtil.colorize( + " &7Villager: " + MsgUtil.formatCount( + snap.getVillagers(), + plugin.getSpigotConfig().getVillagersWarning(), + plugin.getSpigotConfig().getVillagersCritical()) + + " &7Items: " + MsgUtil.formatCount( + snap.getItems(), + plugin.getSpigotConfig().getItemsWarning(), + plugin.getSpigotConfig().getItemsCritical()) + + " &7Gesamt: &f" + snap.getTotal() + )); + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp reload + // ────────────────────────────────────────── + + private void handleReload(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.reload")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + plugin.reload(); + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("reload-success"))); + } + + // ────────────────────────────────────────── + // /sp debug + // ────────────────────────────────────────── + + private void handleDebug(CommandSender sender, String prefix) { + if (!sender.hasPermission("serverpulse.debug")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("Debug-Informationen"))); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Plugin-Version: &f" + plugin.getDescription().getVersion() + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Debug-Modus: " + (plugin.getSpigotConfig().isDebugMode() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Datenbank: " + (plugin.getDatabaseManager().isConnected() ? "&aVerbunden" : "&cGetrennt") + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Discord: " + (plugin.getSpigotConfig().isDiscordEnabled() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7REST API: &8(wird auf BungeeCord konfiguriert)" + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Trend-Analyse: " + (plugin.getSpigotConfig().isTrendAnalysisEnabled() ? "&aAktiv" : "&cInaktiv") + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Überwachte Welten: &f" + Bukkit.getWorlds().size() + )); + sender.sendMessage(MsgUtil.colorize( + prefix + "&7Kritische Events (seit Start): &f" + plugin.getAlertManager().getCriticalEventCount() + )); + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + } + + // ────────────────────────────────────────── + // /sp clear + // ────────────────────────────────────────── + + private void handleClear(CommandSender sender, String prefix, String[] args) { + if (!sender.hasPermission("serverpulse.admin")) { + sender.sendMessage(MsgUtil.colorize(prefix + plugin.getSpigotConfig().getMessage("no-permission"))); + return; + } + + if (args.length < 3) { + sender.sendMessage(MsgUtil.colorize(prefix + "&cBenutzung: /sp clear ")); + return; + } + + String type = args[1].toLowerCase(); + String worldName = args[2]; + + if (Bukkit.getWorld(worldName) == null) { + sender.sendMessage(MsgUtil.colorize(prefix + "&cWelt nicht gefunden: &f" + worldName)); + return; + } + + switch (type) { + case "items" -> { + sender.sendMessage(MsgUtil.colorize(prefix + "&eStarte Item-Clear in &f" + worldName + "&e...")); + plugin.getAlertManager().triggerItemClear(worldName); + } + case "mobs" -> { + sender.sendMessage(MsgUtil.colorize(prefix + "&eStarte Mob-Clear in &f" + worldName + "&e...")); + plugin.getAlertManager().triggerMobClear(worldName); + } + default -> sender.sendMessage(MsgUtil.colorize(prefix + "&cUnbekannter Typ. Nutze &fitems &coder &fmobs&c.")); + } + } + + // ────────────────────────────────────────── + // HILFE + // ────────────────────────────────────────── + + private void sendHelp(CommandSender sender, String prefix, String label) { + sender.sendMessage(MsgUtil.colorize(MsgUtil.header("ServerPulse Hilfe"))); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " status &8– &7Server-Performance-Übersicht")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " report &8– &7Vollständiger Statusbericht")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " world &8– &7Welt-Statistiken")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " entities [welt] &8– &7Entity-Übersicht")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " clear &8– &7Notfall-Clear")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " reload &8– &7Konfiguration neu laden")); + sender.sendMessage(MsgUtil.colorize(prefix + "&b/" + label + " debug &8– &7Debug-Informationen")); + sender.sendMessage(MsgUtil.colorize(MsgUtil.separator())); + } + + // ────────────────────────────────────────── + // TAB-COMPLETION + // ────────────────────────────────────────── + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List completions = new ArrayList<>(); + + if (args.length == 1) { + completions.addAll(Arrays.asList("status", "report", "world", "entities", "reload", "debug", "clear")); + } else if (args.length == 2) { + switch (args[0].toLowerCase()) { + case "world", "entities" -> + Bukkit.getWorlds().forEach(w -> completions.add(w.getName())); + case "clear" -> + completions.addAll(Arrays.asList("items", "mobs")); + } + } else if (args.length == 3 && args[0].equalsIgnoreCase("clear")) { + Bukkit.getWorlds().forEach(w -> completions.add(w.getName())); + } + + return completions.stream() + .filter(s -> s.toLowerCase().startsWith(args[args.length - 1].toLowerCase())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/de/serverpulse/spigot/database/DatabaseManager.java b/src/main/java/de/serverpulse/spigot/database/DatabaseManager.java new file mode 100644 index 0000000..e765b89 --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/database/DatabaseManager.java @@ -0,0 +1,446 @@ +package de.serverpulse.spigot.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; + +import java.sql.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +/** + * Verwaltet die MySQL-Datenbankverbindung via HikariCP + * und stellt Methoden für alle CRUD-Operationen bereit. + */ +public class DatabaseManager { + + private final SpigotPlugin plugin; + private HikariDataSource dataSource; + + public DatabaseManager(SpigotPlugin plugin) { + this.plugin = plugin; + } + + // ────────────────────────────────────────── + // VERBINDUNG + // ────────────────────────────────────────── + + /** + * Verbindet mit der MySQL-Datenbank via HikariCP + */ + public boolean connect() { + try { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl("jdbc:mysql://" + + plugin.getSpigotConfig().getDbHost() + ":" + + plugin.getSpigotConfig().getDbPort() + "/" + + plugin.getSpigotConfig().getDbName() + + "?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8"); + hikariConfig.setUsername(plugin.getSpigotConfig().getDbUser()); + hikariConfig.setPassword(plugin.getSpigotConfig().getDbPass()); + + // Pool-Einstellungen + hikariConfig.setMaximumPoolSize(plugin.getSpigotConfig().getPoolMaxSize()); + hikariConfig.setMinimumIdle(plugin.getSpigotConfig().getPoolMinIdle()); + hikariConfig.setConnectionTimeout(plugin.getSpigotConfig().getPoolConnTimeout()); + hikariConfig.setIdleTimeout(plugin.getSpigotConfig().getPoolIdleTimeout()); + hikariConfig.setMaxLifetime(plugin.getSpigotConfig().getPoolMaxLifetime()); + hikariConfig.setPoolName("ServerPulse-Pool"); + + // Performance-Eigenschaften + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + hikariConfig.addDataSourceProperty("useServerPrepStmts", "true"); + + this.dataSource = new HikariDataSource(hikariConfig); + plugin.getLogger().info("Datenbankverbindung erfolgreich hergestellt!"); + return true; + + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "Datenbankverbindung fehlgeschlagen: " + e.getMessage(), e); + return false; + } + } + + /** + * Schließt den Connection Pool + */ + public void disconnect() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + plugin.getLogger().info("Datenbankverbindung getrennt."); + } + } + + /** + * Gibt eine Verbindung aus dem Pool zurück + */ + public Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + public boolean isConnected() { + return dataSource != null && !dataSource.isClosed(); + } + + // ────────────────────────────────────────── + // TABELLEN ERSTELLEN + // ────────────────────────────────────────── + + /** + * Erstellt alle benötigten Tabellen, falls nicht vorhanden + */ + public void createTables() { + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + + // server_metrics: TPS, MSPT, RAM, CPU, Spieler + stmt.execute(""" + CREATE TABLE IF NOT EXISTS server_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + tps DOUBLE NOT NULL, + mspt DOUBLE NOT NULL, + ram_used_mb BIGINT NOT NULL, + ram_max_mb BIGINT NOT NULL, + ram_percent DOUBLE NOT NULL, + online_players INT NOT NULL, + loaded_worlds INT NOT NULL, + loaded_chunks INT NOT NULL, + INDEX idx_recorded_at (recorded_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // world_metrics: Welt-spezifische Daten + stmt.execute(""" + CREATE TABLE IF NOT EXISTS world_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + world_name VARCHAR(100) NOT NULL, + loaded_chunks INT NOT NULL, + total_entities INT NOT NULL, + online_players INT NOT NULL, + INDEX idx_recorded_at (recorded_at), + INDEX idx_world_name (world_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // entity_metrics: Entity-Zahlen pro Welt und Kategorie + stmt.execute(""" + CREATE TABLE IF NOT EXISTS entity_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + world_name VARCHAR(100) NOT NULL, + monsters INT NOT NULL DEFAULT 0, + animals INT NOT NULL DEFAULT 0, + water_mobs INT NOT NULL DEFAULT 0, + villagers INT NOT NULL DEFAULT 0, + armor_stands INT NOT NULL DEFAULT 0, + hopper_minecarts INT NOT NULL DEFAULT 0, + items INT NOT NULL DEFAULT 0, + players INT NOT NULL DEFAULT 0, + other INT NOT NULL DEFAULT 0, + total INT NOT NULL DEFAULT 0, + INDEX idx_recorded_at (recorded_at), + INDEX idx_world_name (world_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // alert_history: Protokoll aller Warnungen + stmt.execute(""" + CREATE TABLE IF NOT EXISTS alert_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + alert_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL, + world_name VARCHAR(100), + message TEXT NOT NULL, + value_current DOUBLE, + value_threshold DOUBLE, + discord_sent BOOLEAN DEFAULT FALSE, + INDEX idx_created_at (created_at), + INDEX idx_severity (severity), + INDEX idx_alert_type (alert_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + // report_history: Protokoll generierter Reports + stmt.execute(""" + CREATE TABLE IF NOT EXISTS report_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + report_type VARCHAR(50) NOT NULL, + generated_by VARCHAR(100) NOT NULL, + content TEXT, + discord_sent BOOLEAN DEFAULT FALSE, + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """); + + plugin.getLogger().info("Datenbanktabellen erfolgreich erstellt/verifiziert."); + + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e); + } + } + + // ────────────────────────────────────────── + // SERVER METRICS + // ────────────────────────────────────────── + + /** + * Speichert einen Performance-Snapshot + */ + public void saveServerMetrics(PerformanceSnapshot snapshot) { + String sql = """ + INSERT INTO server_metrics + (recorded_at, tps, mspt, ram_used_mb, ram_max_mb, ram_percent, online_players, loaded_worlds, loaded_chunks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setTimestamp(1, Timestamp.valueOf(snapshot.getRecordedAt())); + ps.setDouble(2, snapshot.getTps()); + ps.setDouble(3, snapshot.getMspt()); + ps.setLong(4, snapshot.getRamUsedMb()); + ps.setLong(5, snapshot.getRamMaxMb()); + ps.setDouble(6, snapshot.getRamPercent()); + ps.setInt(7, snapshot.getOnlinePlayers()); + ps.setInt(8, snapshot.getLoadedWorlds()); + ps.setInt(9, snapshot.getLoadedChunks()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Server-Metriken: " + e.getMessage()); + } + } + + /** + * Gibt die letzten N Performance-Snapshots zurück + */ + public List getRecentServerMetrics(int limit) { + List results = new ArrayList<>(); + String sql = "SELECT * FROM server_metrics ORDER BY recorded_at DESC LIMIT ?"; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, limit); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + results.add(mapServerMetrics(rs)); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Server-Metriken: " + e.getMessage()); + } + return results; + } + + /** + * Durchschnittliche TPS der letzten X Minuten + */ + public double getAverageTps(int minutes) { + String sql = "SELECT AVG(tps) FROM server_metrics WHERE recorded_at >= NOW() - INTERVAL ? MINUTE"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, minutes); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getDouble(1); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Berechnen der Durchschnitts-TPS: " + e.getMessage()); + } + return 20.0; + } + + // ────────────────────────────────────────── + // ENTITY METRICS + // ────────────────────────────────────────── + + /** + * Speichert einen Entity-Snapshot + */ + public void saveEntityMetrics(EntitySnapshot snapshot) { + String sql = """ + INSERT INTO entity_metrics + (recorded_at, world_name, monsters, animals, water_mobs, villagers, + armor_stands, hopper_minecarts, items, players, other, total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setTimestamp(1, Timestamp.valueOf(snapshot.getRecordedAt())); + ps.setString(2, snapshot.getWorldName()); + ps.setInt(3, snapshot.getMonsters()); + ps.setInt(4, snapshot.getAnimals()); + ps.setInt(5, snapshot.getWaterMobs()); + ps.setInt(6, snapshot.getVillagers()); + ps.setInt(7, snapshot.getArmorStands()); + ps.setInt(8, snapshot.getHopperMinecarts()); + ps.setInt(9, snapshot.getItems()); + ps.setInt(10, snapshot.getPlayers()); + ps.setInt(11, snapshot.getOther()); + ps.setInt(12, snapshot.getTotal()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Entity-Metriken: " + e.getMessage()); + } + } + + /** + * Letzte Entity-Metriken für eine Welt + */ + public EntitySnapshot getLatestEntityMetrics(String worldName) { + String sql = "SELECT * FROM entity_metrics WHERE world_name = ? ORDER BY recorded_at DESC LIMIT 1"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, worldName); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return mapEntityMetrics(rs); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Entity-Metriken: " + e.getMessage()); + } + return null; + } + + /** + * Entity-Verlauf für Trendanalyse + */ + public List getEntityHistory(String worldName, int dataPoints) { + List results = new ArrayList<>(); + String sql = "SELECT * FROM entity_metrics WHERE world_name = ? ORDER BY recorded_at DESC LIMIT ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, worldName); + ps.setInt(2, dataPoints); + ResultSet rs = ps.executeQuery(); + while (rs.next()) results.add(mapEntityMetrics(rs)); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Laden der Entity-Historie: " + e.getMessage()); + } + return results; + } + + // ────────────────────────────────────────── + // ALERT HISTORY + // ────────────────────────────────────────── + + /** + * Speichert eine Warnung in der Datenbank + */ + public void saveAlert(String alertType, String severity, String worldName, + String message, Double currentValue, Double threshold) { + String sql = """ + INSERT INTO alert_history (alert_type, severity, world_name, message, value_current, value_threshold) + VALUES (?, ?, ?, ?, ?, ?) + """; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, alertType); + ps.setString(2, severity); + ps.setString(3, worldName); + ps.setString(4, message); + if (currentValue != null) ps.setDouble(5, currentValue); + else ps.setNull(5, Types.DOUBLE); + if (threshold != null) ps.setDouble(6, threshold); + else ps.setNull(6, Types.DOUBLE); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern der Warnung: " + e.getMessage()); + } + } + + /** + * Anzahl der Warnungen der letzten X Stunden + */ + public int getAlertCount(int hours, String severity) { + String sql = "SELECT COUNT(*) FROM alert_history WHERE created_at >= NOW() - INTERVAL ? HOUR AND severity = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, hours); + ps.setString(2, severity); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getInt(1); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Zählen der Warnungen: " + e.getMessage()); + } + return 0; + } + + // ────────────────────────────────────────── + // REPORT HISTORY + // ────────────────────────────────────────── + + /** + * Speichert einen generierten Report + */ + public void saveReport(String reportType, String generatedBy, String content) { + String sql = "INSERT INTO report_history (report_type, generated_by, content) VALUES (?, ?, ?)"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, reportType); + ps.setString(2, generatedBy); + ps.setString(3, content); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler beim Speichern des Reports: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // DATEN-BEREINIGUNG + // ────────────────────────────────────────── + + /** + * Löscht alte Daten basierend auf der konfigurierten Retention-Zeit + */ + public void cleanupOldData() { + int days = plugin.getSpigotConfig().getDataRetentionDays(); + if (days <= 0) return; + + String[] tables = {"server_metrics", "world_metrics", "entity_metrics", "alert_history", "report_history"}; + try (Connection conn = getConnection()) { + for (String table : tables) { + try (PreparedStatement ps = conn.prepareStatement( + "DELETE FROM " + table + " WHERE recorded_at < NOW() - INTERVAL ? DAY")) { + ps.setInt(1, days); + int deleted = ps.executeUpdate(); + if (deleted > 0) { + plugin.debug("Bereinigt: " + deleted + " Zeilen aus " + table); + } + } + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Fehler bei der Datenbereinigung: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // MAPPER + // ────────────────────────────────────────── + + private PerformanceSnapshot mapServerMetrics(ResultSet rs) throws SQLException { + return new PerformanceSnapshot( + rs.getTimestamp("recorded_at").toLocalDateTime(), + rs.getDouble("tps"), + rs.getDouble("mspt"), + rs.getLong("ram_used_mb"), + rs.getLong("ram_max_mb"), + rs.getDouble("ram_percent"), + rs.getInt("online_players"), + rs.getInt("loaded_worlds"), + rs.getInt("loaded_chunks") + ); + } + + private EntitySnapshot mapEntityMetrics(ResultSet rs) throws SQLException { + return new EntitySnapshot( + rs.getTimestamp("recorded_at").toLocalDateTime(), + rs.getString("world_name"), + rs.getInt("monsters"), + rs.getInt("animals"), + rs.getInt("water_mobs"), + rs.getInt("villagers"), + rs.getInt("armor_stands"), + rs.getInt("hopper_minecarts"), + rs.getInt("items"), + rs.getInt("players"), + rs.getInt("other"), + rs.getInt("total") + ); + } +} diff --git a/src/main/java/de/serverpulse/spigot/discord/SpigotDiscordWebhook.java b/src/main/java/de/serverpulse/spigot/discord/SpigotDiscordWebhook.java new file mode 100644 index 0000000..0925b76 --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/discord/SpigotDiscordWebhook.java @@ -0,0 +1,270 @@ +package de.serverpulse.spigot.discord; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; +import de.serverpulse.spigot.utils.MsgUtil; +import okhttp3.*; + +import java.io.IOException; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * Verwaltet alle Discord-Webhook-Benachrichtigungen. + * Unterstützt Rich Embeds mit Farben, Feldern und Avataren. + */ +public class SpigotDiscordWebhook { + + private final SpigotPlugin plugin; + private final OkHttpClient httpClient; + + public SpigotDiscordWebhook(SpigotPlugin plugin) { + this.plugin = plugin; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + // ────────────────────────────────────────── + // ALERT SENDEN + // ────────────────────────────────────────── + + /** + * Sendet eine Warnung als Discord-Embed + */ + public void sendAlert(String alertType, AlertSeverity severity, String worldName, + String message, Double currentValue, Double threshold) { + if (!plugin.getSpigotConfig().isDiscordEnabled()) return; + + int color = switch (severity) { + case CRITICAL -> plugin.getSpigotConfig().getColorCritical(); + case WARNING -> plugin.getSpigotConfig().getColorWarning(); + default -> plugin.getSpigotConfig().getColorInfo(); + }; + + String emoji = switch (severity) { + case CRITICAL -> "🔴"; + case WARNING -> "🟡"; + default -> "🔵"; + }; + + JsonObject embed = createEmbed( + emoji + " " + severity.getName() + " – " + formatAlertType(alertType), + message, + color + ); + + // Felder hinzufügen + JsonArray fields = new JsonArray(); + + if (worldName != null) { + fields.add(createField("🌍 Welt", worldName, true)); + } + + if (currentValue != null) { + fields.add(createField("📊 Aktueller Wert", String.format("%.2f", currentValue), true)); + } + + if (threshold != null) { + fields.add(createField("⚠️ Grenzwert", String.format("%.2f", threshold), true)); + } + + fields.add(createField("🕐 Zeitpunkt", MsgUtil.timestamp(), false)); + + embed.add("fields", fields); + sendEmbed(plugin.getSpigotConfig().getWebhookUrl(), embed); + } + + // ────────────────────────────────────────── + // TÄGLICHER REPORT + // ────────────────────────────────────────── + + /** + * Plant den täglichen Status-Report + */ + public void scheduleDailyReport() { + String timeStr = plugin.getSpigotConfig().getDailyReportTime(); + try { + LocalTime reportTime = LocalTime.parse(timeStr, DateTimeFormatter.ofPattern("HH:mm")); + LocalTime now = LocalTime.now(); + + long secondsUntilReport; + if (now.isBefore(reportTime)) { + secondsUntilReport = now.until(reportTime, java.time.temporal.ChronoUnit.SECONDS); + } else { + secondsUntilReport = now.until(reportTime.plusHours(24), java.time.temporal.ChronoUnit.SECONDS); + } + + long ticksUntilFirst = secondsUntilReport * 20; + long ticksPerDay = 24 * 60 * 60 * 20; + + org.bukkit.Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + this::sendDailyReport, ticksUntilFirst, ticksPerDay); + + plugin.getLogger().info("Täglicher Discord-Report geplant für " + timeStr + " Uhr."); + + } catch (Exception e) { + plugin.getLogger().warning("Ungültige Report-Zeit in Config: " + timeStr); + } + } + + /** + * Erstellt und sendet den täglichen Status-Report + */ + public void sendDailyReport() { + if (!plugin.getSpigotConfig().isDiscordEnabled()) return; + + JsonObject embed = createEmbed( + "📊 Täglicher Server-Statusbericht", + "Hier ist der automatische Tagesbericht für deinen Minecraft-Server.", + plugin.getSpigotConfig().getColorInfo() + ); + + JsonArray fields = new JsonArray(); + + // Performance-Daten + PerformanceSnapshot perf = plugin.getPerformanceMonitor().getLastSnapshot(); + if (perf != null) { + fields.add(createField("⚡ TPS (aktuell)", + String.format("%.2f / 20.0", perf.getTps()), true)); + fields.add(createField("⏱ MSPT", + String.format("%.2f ms", perf.getMspt()), true)); + fields.add(createField("💾 RAM", + perf.getRamUsedMb() + " MB / " + perf.getRamMaxMb() + " MB", true)); + fields.add(createField("👥 Online", + perf.getOnlinePlayers() + " Spieler", true)); + fields.add(createField("🗺 Welten", + perf.getLoadedWorlds() + " geladen", true)); + fields.add(createField("📦 Chunks", + perf.getLoadedChunks() + " geladen", true)); + } + + // Durchschnittliche TPS der letzten 24h + if (plugin.getDatabaseManager().isConnected()) { + double avgTps24h = plugin.getDatabaseManager().getAverageTps(60 * 24); + fields.add(createField("📈 Ø TPS (24h)", + String.format("%.2f", avgTps24h), true)); + + int criticalAlerts = plugin.getDatabaseManager().getAlertCount(24, "CRITICAL"); + int warningAlerts = plugin.getDatabaseManager().getAlertCount(24, "WARNING"); + fields.add(createField("🚨 Kritische Warnungen (24h)", + String.valueOf(criticalAlerts), true)); + fields.add(createField("⚠️ Warnungen (24h)", + String.valueOf(warningAlerts), true)); + } + + // Entity-Daten pro Welt + for (var entry : plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + EntitySnapshot snap = entry.getValue(); + String displayName = plugin.getSpigotConfig().getWorldDisplayName(entry.getKey()); + fields.add(createField("🌍 " + displayName, + "Gesamt: **" + snap.getTotal() + "** | Monster: " + snap.getMonsters() + + " | Tiere: " + snap.getAnimals() + + " | Villager: " + snap.getVillagers() + + " | Items: " + snap.getItems(), + false)); + } + + fields.add(createField("🕐 Erstellt", MsgUtil.timestamp(), false)); + + embed.add("fields", fields); + + // Footer + JsonObject footer = new JsonObject(); + footer.addProperty("text", "ServerPulse Monitoring System"); + embed.add("footer", footer); + + sendEmbed(plugin.getSpigotConfig().getReportWebhookUrl(), embed); + } + + /** + * Sendet eine einfache Info-Nachricht + */ + public void sendInfo(String title, String message) { + if (!plugin.getSpigotConfig().isDiscordEnabled()) return; + + JsonObject embed = createEmbed(title, message, plugin.getSpigotConfig().getColorInfo()); + sendEmbed(plugin.getSpigotConfig().getWebhookUrl(), embed); + } + + // ────────────────────────────────────────── + // HTTP-VERSAND + // ────────────────────────────────────────── + + /** + * Sendet ein Discord-Embed an einen Webhook + */ + private void sendEmbed(String webhookUrl, JsonObject embed) { + if (webhookUrl == null || webhookUrl.isEmpty() || webhookUrl.contains("YOUR_WEBHOOK")) { + plugin.debug("Discord-Webhook nicht konfiguriert, überspringe."); + return; + } + + JsonObject payload = new JsonObject(); + payload.addProperty("username", plugin.getSpigotConfig().getBotName()); + + String avatarUrl = plugin.getSpigotConfig().getBotAvatar(); + if (!avatarUrl.isEmpty()) { + payload.addProperty("avatar_url", avatarUrl); + } + + JsonArray embeds = new JsonArray(); + embeds.add(embed); + payload.add("embeds", embeds); + + org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + RequestBody body = RequestBody.create( + payload.toString(), + MediaType.get("application/json") + ); + Request request = new Request.Builder() + .url(webhookUrl) + .post(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + plugin.getLogger().warning("Discord-Webhook Fehler: " + response.code()); + } else { + plugin.debug("Discord-Nachricht erfolgreich gesendet."); + } + } + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Discord-Webhook Verbindungsfehler: " + e.getMessage()); + } + }); + } + + // ────────────────────────────────────────── + // HILFSMETHODEN + // ────────────────────────────────────────── + + private JsonObject createEmbed(String title, String description, int color) { + JsonObject embed = new JsonObject(); + embed.addProperty("title", title); + embed.addProperty("description", description); + embed.addProperty("color", color); + return embed; + } + + private JsonObject createField(String name, String value, boolean inline) { + JsonObject field = new JsonObject(); + field.addProperty("name", name); + field.addProperty("value", value.isEmpty() ? "N/A" : value); + field.addProperty("inline", inline); + return field; + } + + private String formatAlertType(String alertType) { + return alertType.replace("_", " "); + } +} diff --git a/src/main/java/de/serverpulse/spigot/listeners/SpigotPlayerListener.java b/src/main/java/de/serverpulse/spigot/listeners/SpigotPlayerListener.java new file mode 100644 index 0000000..8173316 --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/listeners/SpigotPlayerListener.java @@ -0,0 +1,27 @@ +package de.serverpulse.spigot.listeners; + +import de.serverpulse.spigot.SpigotPlugin; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public class SpigotPlayerListener implements Listener { + + private final SpigotPlugin plugin; + + public SpigotPlayerListener(SpigotPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent e) { + plugin.debug("Spieler verbunden: " + e.getPlayer().getName()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent e) { + plugin.debug("Spieler getrennt: " + e.getPlayer().getName()); + } +} diff --git a/src/main/java/de/serverpulse/spigot/monitoring/EntityMonitor.java b/src/main/java/de/serverpulse/spigot/monitoring/EntityMonitor.java new file mode 100644 index 0000000..12ab82a --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/monitoring/EntityMonitor.java @@ -0,0 +1,212 @@ +package de.serverpulse.spigot.monitoring; + +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.*; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.entity.minecart.HopperMinecart; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Erfasst und kategorisiert alle Entitäten pro Welt. + * Kategorien: Monster, Tiere, Wasser-Mobs, Villager, + * ArmorStands, Hopper-Minecarts, Items, Spieler, Sonstige. + */ +public class EntityMonitor { + + private final SpigotPlugin plugin; + private BukkitTask task; + + // Cache: letzter Snapshot pro Welt + private final Map lastSnapshots = new ConcurrentHashMap<>(); + + public EntityMonitor(SpigotPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + int intervalSeconds = plugin.getSpigotConfig().getEntityInterval(); + int intervalTicks = intervalSeconds * 20; + + // Entity-Zählung muss auf dem Haupt-Thread laufen (Bukkit API) + task = Bukkit.getScheduler().runTaskTimer(plugin, this::collect, 200L, intervalTicks); + plugin.getLogger().info("Entity Monitor gestartet (Intervall: " + intervalSeconds + "s)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Entity Monitor gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // DATENERFASSUNG + // ────────────────────────────────────────── + + private void collect() { + for (World world : Bukkit.getWorlds()) { + if (!plugin.getSpigotConfig().isWorldMonitored(world.getName())) continue; + + EntitySnapshot snapshot = countEntities(world); + lastSnapshots.put(world.getName(), snapshot); + + // Async in DB speichern + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveEntityMetrics(snapshot); + } + checkEntityThresholds(snapshot); + }); + + plugin.debug("Entities in " + world.getName() + ": " + snapshot.getTotal() + " gesamt"); + } + } + + /** + * Zählt und kategorisiert alle Entitäten einer Welt + */ + public EntitySnapshot countEntities(World world) { + int monsters = 0, animals = 0, waterMobs = 0, villagers = 0; + int armorStands = 0, hopperMinecarts = 0, items = 0, players = 0, other = 0; + + for (Entity entity : world.getEntities()) { + if (entity instanceof Monster) { + monsters++; + } else if (entity instanceof WaterMob) { + waterMobs++; + } else if (entity instanceof Villager || entity instanceof WanderingTrader) { + villagers++; + } else if (entity instanceof Animals || entity instanceof Golem) { + animals++; + } else if (entity instanceof ArmorStand) { + armorStands++; + } else if (entity instanceof HopperMinecart) { + hopperMinecarts++; + } else if (entity instanceof Item) { + items++; + } else if (entity instanceof Player) { + players++; + } else { + other++; + } + } + + int total = monsters + animals + waterMobs + villagers + + armorStands + hopperMinecarts + items + players + other; + + return new EntitySnapshot( + LocalDateTime.now(), world.getName(), + monsters, animals, waterMobs, villagers, + armorStands, hopperMinecarts, items, players, other, total + ); + } + + // ────────────────────────────────────────── + // GRENZWERT-PRÜFUNG + // ────────────────────────────────────────── + + private void checkEntityThresholds(EntitySnapshot snapshot) { + String world = snapshot.getWorldName(); + + // Gesamt-Entities + int totalWarning = plugin.getSpigotConfig().getWorldTotalWarning(world); + int totalCritical = plugin.getSpigotConfig().getWorldTotalCritical(world); + + if (snapshot.getTotal() >= totalCritical) { + plugin.getAlertManager().triggerAlert( + "ENTITY_TOTAL_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch hohe Entity-Anzahl in " + world + ": " + snapshot.getTotal(), + (double) snapshot.getTotal(), (double) totalCritical + ); + } else if (snapshot.getTotal() >= totalWarning) { + plugin.getAlertManager().triggerAlert( + "ENTITY_TOTAL_WARNING", AlertSeverity.WARNING, world, + "Hohe Entity-Anzahl in " + world + ": " + snapshot.getTotal(), + (double) snapshot.getTotal(), (double) totalWarning + ); + } + + // Monster + int monstersWarning = plugin.getSpigotConfig().getWorldMonstersWarning(world); + int monstersCritical = plugin.getSpigotConfig().getWorldMonstersCritical(world); + + if (snapshot.getMonsters() >= monstersCritical) { + plugin.getAlertManager().triggerAlert( + "MONSTERS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Monster in " + world + ": " + snapshot.getMonsters(), + (double) snapshot.getMonsters(), (double) monstersCritical + ); + } else if (snapshot.getMonsters() >= monstersWarning) { + plugin.getAlertManager().triggerAlert( + "MONSTERS_WARNING", AlertSeverity.WARNING, world, + "Viele Monster in " + world + ": " + snapshot.getMonsters(), + (double) snapshot.getMonsters(), (double) monstersWarning + ); + } + + // Items + int itemsWarning = plugin.getSpigotConfig().getItemsWarning(); + int itemsCritical = plugin.getSpigotConfig().getItemsCritical(); + + if (snapshot.getItems() >= itemsCritical) { + plugin.getAlertManager().triggerAlert( + "ITEMS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Items in " + world + ": " + snapshot.getItems(), + (double) snapshot.getItems(), (double) itemsCritical + ); + // Notfall-Item-Clear auslösen + if (plugin.getSpigotConfig().isItemClearEnabled()) { + plugin.getAlertManager().triggerItemClear(world); + } + } else if (snapshot.getItems() >= itemsWarning) { + plugin.getAlertManager().triggerAlert( + "ITEMS_WARNING", AlertSeverity.WARNING, world, + "Viele Items in " + world + ": " + snapshot.getItems(), + (double) snapshot.getItems(), (double) itemsWarning + ); + } + + // Villager + int villagersWarning = plugin.getSpigotConfig().getVillagersWarning(); + int villagersCritical = plugin.getSpigotConfig().getVillagersCritical(); + + if (snapshot.getVillagers() >= villagersCritical) { + plugin.getAlertManager().triggerAlert( + "VILLAGERS_CRITICAL", AlertSeverity.CRITICAL, world, + "Kritisch viele Villager in " + world + ": " + snapshot.getVillagers(), + (double) snapshot.getVillagers(), (double) villagersCritical + ); + } else if (snapshot.getVillagers() >= villagersWarning) { + plugin.getAlertManager().triggerAlert( + "VILLAGERS_WARNING", AlertSeverity.WARNING, world, + "Viele Villager in " + world + ": " + snapshot.getVillagers(), + (double) snapshot.getVillagers(), (double) villagersWarning + ); + } + } + + // ────────────────────────────────────────── + // GETTER + // ────────────────────────────────────────── + + public Map getAllLastSnapshots() { + return new HashMap<>(lastSnapshots); + } + + public EntitySnapshot getLastSnapshot(String worldName) { + return lastSnapshots.get(worldName); + } +} diff --git a/src/main/java/de/serverpulse/spigot/monitoring/PerformanceMonitor.java b/src/main/java/de/serverpulse/spigot/monitoring/PerformanceMonitor.java new file mode 100644 index 0000000..80a740e --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/monitoring/PerformanceMonitor.java @@ -0,0 +1,213 @@ +package de.serverpulse.spigot.monitoring; + +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.PerformanceSnapshot; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; + +import java.time.LocalDateTime; + +/** + * Erfasst Server-Performance-Daten: TPS, MSPT, RAM, Spieler, Chunks. + * Läuft als wiederkehrender Bukkit-Task. + */ +public class PerformanceMonitor { + + private final SpigotPlugin plugin; + private BukkitTask task; + + // Letzter bekannter Snapshot (für Status-Befehle) + private PerformanceSnapshot lastSnapshot; + + public PerformanceMonitor(SpigotPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + int intervalSeconds = plugin.getSpigotConfig().getMetricsInterval(); + int intervalTicks = intervalSeconds * 20; + + task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::collect, 100L, intervalTicks); + plugin.getLogger().info("Performance Monitor gestartet (Intervall: " + intervalSeconds + "s)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Performance Monitor gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // DATENERFASSUNG + // ────────────────────────────────────────── + + private void collect() { + try { + double tps = getTps(); + double mspt = getMspt(); + long ramUsed = getRamUsedMb(); + long ramMax = getRamMaxMb(); + double ramPercent = (double) ramUsed / ramMax * 100; + int onlinePlayers = Bukkit.getOnlinePlayers().size(); + int loadedWorlds = Bukkit.getWorlds().size(); + int loadedChunks = getTotalLoadedChunks(); + + PerformanceSnapshot snapshot = new PerformanceSnapshot( + LocalDateTime.now(), tps, mspt, + ramUsed, ramMax, ramPercent, + onlinePlayers, loadedWorlds, loadedChunks + ); + + this.lastSnapshot = snapshot; + + // In DB speichern + if (plugin.getDatabaseManager().isConnected()) { + plugin.getDatabaseManager().saveServerMetrics(snapshot); + } + + // Grenzwerte prüfen + checkThresholds(snapshot); + + plugin.debug("Performance erfasst: TPS=" + String.format("%.2f", tps) + + " MSPT=" + String.format("%.2f", mspt) + + " RAM=" + ramUsed + "MB/" + ramMax + "MB"); + + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Erfassen der Performance: " + e.getMessage()); + } + } + + // ────────────────────────────────────────── + // GRENZWERT-PRÜFUNG + // ────────────────────────────────────────── + + private void checkThresholds(PerformanceSnapshot snapshot) { + double tps = snapshot.getTps(); + double mspt = snapshot.getMspt(); + double ramPercent = snapshot.getRamPercent(); + + // TPS prüfen + if (tps < plugin.getSpigotConfig().getTpsCritical()) { + plugin.getAlertManager().triggerAlert( + "TPS_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritisch niedrige TPS: " + String.format("%.2f", tps), + tps, plugin.getSpigotConfig().getTpsCritical() + ); + } else if (tps < plugin.getSpigotConfig().getTpsWarning()) { + plugin.getAlertManager().triggerAlert( + "TPS_WARNING", AlertSeverity.WARNING, null, + "Niedrige TPS: " + String.format("%.2f", tps), + tps, plugin.getSpigotConfig().getTpsWarning() + ); + } + + // MSPT prüfen + if (mspt > plugin.getSpigotConfig().getMsptCritical()) { + plugin.getAlertManager().triggerAlert( + "MSPT_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritisch hohe MSPT: " + String.format("%.2f", mspt) + "ms", + mspt, plugin.getSpigotConfig().getMsptCritical() + ); + } else if (mspt > plugin.getSpigotConfig().getMsptWarning()) { + plugin.getAlertManager().triggerAlert( + "MSPT_WARNING", AlertSeverity.WARNING, null, + "Hohe MSPT: " + String.format("%.2f", mspt) + "ms", + mspt, plugin.getSpigotConfig().getMsptWarning() + ); + } + + // RAM prüfen + if (ramPercent > plugin.getSpigotConfig().getRamCritical()) { + plugin.getAlertManager().triggerAlert( + "RAM_CRITICAL", AlertSeverity.CRITICAL, null, + "Kritische RAM-Auslastung: " + String.format("%.1f", ramPercent) + "%", + ramPercent, (double) plugin.getSpigotConfig().getRamCritical() + ); + } else if (ramPercent > plugin.getSpigotConfig().getRamWarning()) { + plugin.getAlertManager().triggerAlert( + "RAM_WARNING", AlertSeverity.WARNING, null, + "Hohe RAM-Auslastung: " + String.format("%.1f", ramPercent) + "%", + ramPercent, (double) plugin.getSpigotConfig().getRamWarning() + ); + } + } + + // ────────────────────────────────────────── + // TPS & MSPT ABRUFEN + // ────────────────────────────────────────── + + /** + * Liest den aktuellen TPS-Wert vom Server. + * Paper API bietet getTPS(), Spigot hat recentTps[]. + */ + public double getTps() { + try { + // Paper API: getServer().getTPS() + double[] tps = Bukkit.getServer().getTPS(); + return Math.min(20.0, tps[0]); // 1-Minuten-Durchschnitt + } catch (Exception e) { + // Fallback für Spigot + return 20.0; + } + } + + /** + * Liest den aktuellen MSPT-Wert. + * Paper: getAverageTickTime() + */ + public double getMspt() { + try { + return Bukkit.getServer().getAverageTickTime(); + } catch (Exception e) { + // Berechnung aus TPS als Fallback + double tps = getTps(); + return tps > 0 ? 1000.0 / tps : 50.0; + } + } + + // ────────────────────────────────────────── + // RAM + // ────────────────────────────────────────── + + public long getRamUsedMb() { + Runtime runtime = Runtime.getRuntime(); + return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024); + } + + public long getRamMaxMb() { + return Runtime.getRuntime().maxMemory() / (1024 * 1024); + } + + public long getRamFreeMb() { + Runtime runtime = Runtime.getRuntime(); + return (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / (1024 * 1024); + } + + // ────────────────────────────────────────── + // CHUNKS + // ────────────────────────────────────────── + + private int getTotalLoadedChunks() { + int total = 0; + for (World world : Bukkit.getWorlds()) { + total += world.getLoadedChunks().length; + } + return total; + } + + // ────────────────────────────────────────── + // GETTER + // ────────────────────────────────────────── + + public PerformanceSnapshot getLastSnapshot() { + return lastSnapshot; + } +} diff --git a/src/main/java/de/serverpulse/spigot/monitoring/TrendAnalyzer.java b/src/main/java/de/serverpulse/spigot/monitoring/TrendAnalyzer.java new file mode 100644 index 0000000..2a03dba --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/monitoring/TrendAnalyzer.java @@ -0,0 +1,200 @@ +package de.serverpulse.spigot.monitoring; + +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.AlertSeverity; +import de.serverpulse.models.EntitySnapshot; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; + +import java.util.List; + +/** + * Analysiert Trends in den gesammelten Daten. + * Erkennt kontinuierlich steigende Werte wie Monster-Anzahl, + * MSPT-Werte oder RAM-Auslastung. + */ +public class TrendAnalyzer { + + private final SpigotPlugin plugin; + private BukkitTask task; + + public TrendAnalyzer(SpigotPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + if (!plugin.getSpigotConfig().isTrendAnalysisEnabled()) { + plugin.getLogger().info("Trend-Analyse ist deaktiviert."); + return; + } + + int intervalMinutes = plugin.getSpigotConfig().getTrendCheckInterval(); + long intervalTicks = intervalMinutes * 60L * 20L; + + task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::analyze, 600L, intervalTicks); + plugin.getLogger().info("Trend Analyzer gestartet (Intervall: " + intervalMinutes + " Minuten)"); + } + + public void stop() { + if (task != null && !task.isCancelled()) { + task.cancel(); + plugin.getLogger().info("Trend Analyzer gestoppt."); + } + } + + public void restart() { + stop(); + start(); + } + + // ────────────────────────────────────────── + // ANALYSE + // ────────────────────────────────────────── + + private void analyze() { + if (!plugin.getDatabaseManager().isConnected()) return; + + // TPS-Trend analysieren + analyzeTpsTrend(); + + // Entity-Trends analysieren (pro Welt) + for (org.bukkit.World world : Bukkit.getWorlds()) { + if (!plugin.getSpigotConfig().isWorldMonitored(world.getName())) continue; + analyzeEntityTrend(world.getName()); + } + + plugin.debug("Trend-Analyse abgeschlossen."); + } + + /** + * Analysiert den TPS-Trend der letzten Datenpunkte + */ + private void analyzeTpsTrend() { + int dataPoints = plugin.getSpigotConfig().getTrendDataPoints(); + List snapshots = + plugin.getDatabaseManager().getRecentServerMetrics(dataPoints); + + if (snapshots.size() < 3) return; + + // Prüfen ob TPS kontinuierlich fällt + boolean continuousDecline = true; + for (int i = 0; i < snapshots.size() - 1; i++) { + if (snapshots.get(i).getTps() >= snapshots.get(i + 1).getTps()) { + continuousDecline = false; + break; + } + } + + if (continuousDecline) { + double startTps = snapshots.get(snapshots.size() - 1).getTps(); + double endTps = snapshots.get(0).getTps(); + double decline = ((startTps - endTps) / startTps) * 100; + + if (decline >= plugin.getSpigotConfig().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "TPS_TREND_DECLINE", AlertSeverity.WARNING, null, + String.format("TPS-Abnahme-Trend erkannt: %.1f%% Rückgang über %d Messungen", + decline, dataPoints), + endTps, startTps + ); + } + } + + // Prüfen ob MSPT kontinuierlich steigt + boolean continuousMsptIncrease = true; + for (int i = 1; i < snapshots.size(); i++) { + if (snapshots.get(i).getMspt() <= snapshots.get(i - 1).getMspt()) { + continuousMsptIncrease = false; + break; + } + } + + if (continuousMsptIncrease && snapshots.size() >= 3) { + double startMspt = snapshots.get(snapshots.size() - 1).getMspt(); + double endMspt = snapshots.get(0).getMspt(); + double increase = ((endMspt - startMspt) / startMspt) * 100; + + if (increase >= plugin.getSpigotConfig().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "MSPT_TREND_INCREASE", AlertSeverity.WARNING, null, + String.format("MSPT-Anstiegs-Trend erkannt: %.1f%% Anstieg über %d Messungen", + increase, dataPoints), + endMspt, startMspt + ); + } + } + } + + /** + * Analysiert Entity-Trends für eine spezifische Welt + */ + private void analyzeEntityTrend(String worldName) { + int dataPoints = plugin.getSpigotConfig().getTrendDataPoints(); + List snapshots = + plugin.getDatabaseManager().getEntityHistory(worldName, dataPoints); + + if (snapshots.size() < 3) return; + + // Monster-Trend analysieren + boolean monsterIncrease = checkIncreasingTrend( + snapshots.stream().mapToDouble(EntitySnapshot::getMonsters).toArray() + ); + + if (monsterIncrease) { + double startMonsters = snapshots.get(snapshots.size() - 1).getMonsters(); + double endMonsters = snapshots.get(0).getMonsters(); + + if (startMonsters > 0) { + double increase = ((endMonsters - startMonsters) / startMonsters) * 100; + + if (increase >= plugin.getSpigotConfig().getTrendIncreaseThreshold()) { + plugin.getAlertManager().triggerAlert( + "MONSTER_TREND_INCREASE", AlertSeverity.WARNING, worldName, + String.format("Monster-Anstiegs-Trend in %s: %.1f%% Anstieg (%d → %d)", + worldName, increase, (int) startMonsters, (int) endMonsters), + endMonsters, startMonsters + ); + } + } + } + + // Item-Trend analysieren + boolean itemIncrease = checkIncreasingTrend( + snapshots.stream().mapToDouble(EntitySnapshot::getItems).toArray() + ); + + if (itemIncrease) { + double startItems = snapshots.get(snapshots.size() - 1).getItems(); + double endItems = snapshots.get(0).getItems(); + + if (startItems > 0) { + double increase = ((endItems - startItems) / startItems) * 100; + + if (increase >= plugin.getSpigotConfig().getTrendIncreaseThreshold() * 1.5) { + plugin.getAlertManager().triggerAlert( + "ITEMS_TREND_INCREASE", AlertSeverity.WARNING, worldName, + String.format("Item-Anstiegs-Trend in %s: %.1f%% Anstieg (%d → %d)", + worldName, increase, (int) startItems, (int) endItems), + endItems, startItems + ); + } + } + } + } + + /** + * Prüft ob ein Array von Werten kontinuierlich steigt + * (erlaubt kleine Rückgänge) + */ + private boolean checkIncreasingTrend(double[] values) { + if (values.length < 3) return false; + + int increasingCount = 0; + for (int i = 0; i < values.length - 1; i++) { + if (values[i] > values[i + 1]) increasingCount++; + } + + // Mindestens 70% der Messungen müssen steigen + return (double) increasingCount / (values.length - 1) >= 0.7; + } +} diff --git a/src/main/java/de/serverpulse/spigot/network/SpigotMessenger.java b/src/main/java/de/serverpulse/spigot/network/SpigotMessenger.java new file mode 100644 index 0000000..af3718d --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/network/SpigotMessenger.java @@ -0,0 +1,129 @@ +package de.serverpulse.spigot.network; + +import com.google.gson.JsonObject; +import de.serverpulse.network.NetworkMessage; +import de.serverpulse.spigot.SpigotPlugin; +import de.serverpulse.models.EntitySnapshot; +import de.serverpulse.models.PerformanceSnapshot; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; + +/** + * Sendet Monitoring-Daten vom Spigot-Server an den BungeeCord-Proxy + * via Plugin Messaging Channel "serverpulse:data". + */ +public class SpigotMessenger { + + private final SpigotPlugin plugin; + private BukkitTask performanceTask; + private BukkitTask heartbeatTask; + + public SpigotMessenger(SpigotPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, NetworkMessage.CHANNEL); + + int intervalTicks = plugin.getSpigotConfig().getMetricsInterval() * 20; + performanceTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + this::sendPerformance, 200L, intervalTicks); + + heartbeatTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + this::sendHeartbeat, 300L, 1200L); + + plugin.getLogger().info("BungeeCord-Messenger gestartet (Kanal: " + NetworkMessage.CHANNEL + ")"); + } + + public void stop() { + if (performanceTask != null) performanceTask.cancel(); + if (heartbeatTask != null) heartbeatTask.cancel(); + Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, NetworkMessage.CHANNEL); + } + + private void sendPerformance() { + PerformanceSnapshot snap = plugin.getPerformanceMonitor().getLastSnapshot(); + if (snap == null) return; + + JsonObject msg = new JsonObject(); + msg.addProperty("type", NetworkMessage.TYPE_PERFORMANCE); + msg.addProperty("tps", snap.getTps()); + msg.addProperty("mspt", snap.getMspt()); + msg.addProperty("ram_used_mb", snap.getRamUsedMb()); + msg.addProperty("ram_max_mb", snap.getRamMaxMb()); + msg.addProperty("ram_percent", snap.getRamPercent()); + msg.addProperty("online_players", snap.getOnlinePlayers()); + msg.addProperty("loaded_chunks", snap.getLoadedChunks()); + sendToProxy(msg); + + for (Map.Entry entry : + plugin.getEntityMonitor().getAllLastSnapshots().entrySet()) { + sendEntityData(entry.getValue()); + } + } + + private void sendEntityData(EntitySnapshot snap) { + JsonObject msg = new JsonObject(); + msg.addProperty("type", NetworkMessage.TYPE_ENTITY); + msg.addProperty("world", snap.getWorldName()); + msg.addProperty("monsters", snap.getMonsters()); + msg.addProperty("animals", snap.getAnimals()); + msg.addProperty("water_mobs", snap.getWaterMobs()); + msg.addProperty("villagers", snap.getVillagers()); + msg.addProperty("armor_stands", snap.getArmorStands()); + msg.addProperty("hopper_minecarts", snap.getHopperMinecarts()); + msg.addProperty("items", snap.getItems()); + msg.addProperty("players", snap.getPlayers()); + msg.addProperty("other", snap.getOther()); + msg.addProperty("total", snap.getTotal()); + sendToProxy(msg); + } + + public void sendAlert(String alertType, String severity, String message) { + JsonObject msg = new JsonObject(); + msg.addProperty("type", NetworkMessage.TYPE_ALERT); + msg.addProperty("alert_type", alertType); + msg.addProperty("severity", severity); + msg.addProperty("message", message); + sendToProxy(msg); + } + + public void sendChatEvent(String playerName, String message, String reason) { + JsonObject msg = new JsonObject(); + msg.addProperty("type", NetworkMessage.TYPE_CHAT_EVENT); + msg.addProperty("player", playerName); + msg.addProperty("message", message); + msg.addProperty("reason", reason); + sendToProxy(msg); + } + + private void sendHeartbeat() { + JsonObject msg = new JsonObject(); + msg.addProperty("type", NetworkMessage.TYPE_HEARTBEAT); + sendToProxy(msg); + } + + private void sendToProxy(JsonObject json) { + Bukkit.getScheduler().runTask(plugin, () -> { + Collection players = Bukkit.getOnlinePlayers(); + if (players.isEmpty()) return; + try { + byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream dos = new DataOutputStream(baos)) { + dos.write(jsonBytes); + } + players.iterator().next().sendPluginMessage(plugin, NetworkMessage.CHANNEL, baos.toByteArray()); + } catch (Exception e) { + plugin.getLogger().warning("BungeeCord-Nachricht fehlgeschlagen: " + e.getMessage()); + } + }); + } +} diff --git a/src/main/java/de/serverpulse/spigot/utils/MsgUtil.java b/src/main/java/de/serverpulse/spigot/utils/MsgUtil.java new file mode 100644 index 0000000..cd9b235 --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/utils/MsgUtil.java @@ -0,0 +1,99 @@ +package de.serverpulse.spigot.utils; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.command.CommandSender; + +import java.text.DecimalFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class MsgUtil { + + private static final DecimalFormat DF2 = new DecimalFormat("##.##"); + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"); + + // '─' (U+2500) ist in Minecraft's Standard-Schrift ca. 6px breit. + // Der Chat ist ~320px breit → ~53 Zeichen. + // Mit Prefix "[ServerPulse] " (~14 Zeichen) bleiben ~39 Zeichen übrig. + // Wir setzen LINE_LEN daher auf 38 damit es mit Prefix in eine Zeile passt. + private static final int LINE_LEN = 38; + private static final char LINE_CHAR = '─'; + + private MsgUtil() {} + + // ── Farben ─────────────────────────────────────────────────────────────── + + public static String colorize(String s) { + return s == null ? "" : ChatColor.translateAlternateColorCodes('&', s); + } + + public static void send(CommandSender sender, String msg) { + sender.sendMessage(colorize(msg)); + } + + // ── Zahlenformatierung ─────────────────────────────────────────────────── + + public static String formatTps(double tps) { + return colorize((tps >= 18 ? "&a" : tps >= 15 ? "&e" : "&c") + + DF2.format(Math.min(20.0, tps))); + } + + public static String formatMspt(double mspt) { + return colorize((mspt <= 40 ? "&a" : mspt <= 50 ? "&e" : "&c") + + DF2.format(mspt) + "ms"); + } + + public static String formatRam(long used, long max) { + double pct = (double) used / max * 100; + return colorize((pct <= 75 ? "&a" : pct <= 90 ? "&e" : "&c") + + used + "MB / " + max + "MB (" + (int) pct + "%)"); + } + + public static String formatCount(int val, int warn, int crit) { + return colorize((val >= crit ? "&c" : val >= warn ? "&e" : "&a") + val); + } + + // ── Fortschrittsbalken ─────────────────────────────────────────────────── + + public static String bar(double val, double max, int len) { + int filled = (int) Math.min(Math.round((val / max) * len), len); + double pct = val / max * 100; + String col = pct <= 50 ? "&a" : pct <= 75 ? "&e" : "&c"; + return colorize(col + "█".repeat(filled) + "&8" + "░".repeat(Math.max(0, len - filled))); + } + + // ── Zeitstempel ────────────────────────────────────────────────────────── + + public static String timestamp() { + return LocalDateTime.now().format(DT); + } + + // ── Trennlinien ────────────────────────────────────────────────────────── + + /** + * Einfache Trennlinie – passt zur Chat-Breite mit Prefix. + */ + public static String separator() { + return colorize("&8" + String.valueOf(LINE_CHAR).repeat(LINE_LEN)); + } + + /** + * Zentrierter Header. + * Beispiel: ───── ServerPulse Status ───── + * + * Die Striche werden so berechnet, dass Titel + Striche = LINE_LEN ergibt. + * Wenn der Titel zu lang ist, werden mindestens 3 Striche pro Seite gesetzt. + */ + public static String header(String title) { + int available = LINE_LEN - title.length() - 2; // -2 für Leerzeichen + int left = Math.max(3, available / 2); + int right = Math.max(3, available - left); + + String l = String.valueOf(LINE_CHAR); + return colorize( + "&8" + l.repeat(left) + + " &b&l" + title + + " &8" + l.repeat(right) + ); + } +} diff --git a/src/main/java/de/serverpulse/spigot/utils/SpigotConfig.java b/src/main/java/de/serverpulse/spigot/utils/SpigotConfig.java new file mode 100644 index 0000000..7ce7a5f --- /dev/null +++ b/src/main/java/de/serverpulse/spigot/utils/SpigotConfig.java @@ -0,0 +1,76 @@ +package de.serverpulse.spigot.utils; + +import de.serverpulse.spigot.SpigotPlugin; +import org.bukkit.configuration.file.FileConfiguration; + +public class SpigotConfig { + private final SpigotPlugin plugin; + private FileConfiguration cfg; + + public SpigotConfig(SpigotPlugin plugin) { this.plugin = plugin; } + + public void load() { plugin.saveDefaultConfig(); plugin.reloadConfig(); this.cfg = plugin.getConfig(); } + + public boolean isDebugMode() { return cfg.getBoolean("general.debug", false); } + public int getMetricsInterval() { return Math.max(10, cfg.getInt("general.metrics-interval", 30)); } + public int getEntityInterval() { return Math.max(10, cfg.getInt("general.entity-interval", 60)); } + public int getDataRetentionDays() { return cfg.getInt("general.data-retention-days", 90); } + public boolean isDatabaseEnabled() { return cfg.getBoolean("database.enabled", true); } + public String getDbHost() { return cfg.getString("database.host", "localhost"); } + public int getDbPort() { return cfg.getInt("database.port", 3306); } + public String getDbName() { return cfg.getString("database.database", "serverpulse"); } + public String getDbUser() { return cfg.getString("database.username", "root"); } + public String getDbPass() { return cfg.getString("database.password", ""); } + public int getPoolMaxSize() { return cfg.getInt("database.pool.maximum-pool-size", 10); } + public int getPoolMinIdle() { return cfg.getInt("database.pool.minimum-idle", 2); } + public long getPoolConnTimeout() { return cfg.getLong("database.pool.connection-timeout", 30000); } + public long getPoolIdleTimeout() { return cfg.getLong("database.pool.idle-timeout", 600000); } + public long getPoolMaxLifetime() { return cfg.getLong("database.pool.max-lifetime", 1800000); } + public boolean isDiscordEnabled() { return cfg.getBoolean("discord.enabled", false); } + public String getWebhookUrl() { return cfg.getString("discord.webhook-url", ""); } + public String getReportWebhookUrl() { String u = cfg.getString("discord.report-webhook-url",""); return (u==null||u.isEmpty()) ? getWebhookUrl() : u; } + public String getBotName() { return cfg.getString("discord.bot-name", "ServerPulse"); } + public String getBotAvatar() { return cfg.getString("discord.bot-avatar", ""); } + public boolean isDailyReportEnabled() { return cfg.getBoolean("discord.daily-report.enabled", true) && isDiscordEnabled(); } + public String getDailyReportTime() { return cfg.getString("discord.daily-report.time", "08:00"); } + public int getColorInfo() { return cfg.getInt("discord.colors.info", 3447003); } + public int getColorWarning() { return cfg.getInt("discord.colors.warning", 16776960); } + public int getColorCritical() { return cfg.getInt("discord.colors.critical", 16711680); } + public double getTpsWarning() { return cfg.getDouble("thresholds.performance.tps-warning", 18.0); } + public double getTpsCritical() { return cfg.getDouble("thresholds.performance.tps-critical", 15.0); } + public double getMsptWarning() { return cfg.getDouble("thresholds.performance.mspt-warning", 40.0); } + public double getMsptCritical() { return cfg.getDouble("thresholds.performance.mspt-critical", 50.0); } + public int getRamWarning() { return cfg.getInt("thresholds.performance.ram-warning", 75); } + public int getRamCritical() { return cfg.getInt("thresholds.performance.ram-critical", 90); } + public int getEntityTotalWarning() { return cfg.getInt("thresholds.entities.total-warning", 1000); } + public int getEntityTotalCritical() { return cfg.getInt("thresholds.entities.total-critical", 2000); } + public int getMonstersWarning() { return cfg.getInt("thresholds.entities.monsters-warning", 300); } + public int getMonstersCritical() { return cfg.getInt("thresholds.entities.monsters-critical", 600); } + public int getAnimalsWarning() { return cfg.getInt("thresholds.entities.animals-warning", 200); } + public int getAnimalsCritical() { return cfg.getInt("thresholds.entities.animals-critical", 400); } + public int getVillagersWarning() { return cfg.getInt("thresholds.entities.villagers-warning", 100); } + public int getVillagersCritical() { return cfg.getInt("thresholds.entities.villagers-critical", 200); } + public int getItemsWarning() { return cfg.getInt("thresholds.entities.items-warning", 150); } + public int getItemsCritical() { return cfg.getInt("thresholds.entities.items-critical", 300); } + public boolean isWorldMonitored(String w) { return cfg.getBoolean("worlds."+w+".enabled", true); } + public String getWorldDisplayName(String w) { return cfg.getString("worlds."+w+".display-name", w); } + public int getWorldMonstersWarning(String w) { return cfg.getInt("worlds."+w+".thresholds.monsters-warning", getMonstersWarning()); } + public int getWorldMonstersCritical(String w) { return cfg.getInt("worlds."+w+".thresholds.monsters-critical", getMonstersCritical()); } + public int getWorldTotalWarning(String w) { return cfg.getInt("worlds."+w+".thresholds.total-warning", getEntityTotalWarning()); } + public int getWorldTotalCritical(String w) { return cfg.getInt("worlds."+w+".thresholds.total-critical", getEntityTotalCritical()); } + public boolean isTrendAnalysisEnabled() { return cfg.getBoolean("trend-analysis.enabled", true); } + public int getTrendDataPoints() { return cfg.getInt("trend-analysis.data-points", 10); } + public double getTrendIncreaseThreshold() { return cfg.getDouble("trend-analysis.increase-threshold", 20.0); } + public int getTrendCheckInterval() { return cfg.getInt("trend-analysis.check-interval", 5); } + public boolean isItemClearEnabled() { return cfg.getBoolean("emergency-actions.item-clear.enabled", false); } + public boolean isItemClearBroadcast() { return cfg.getBoolean("emergency-actions.item-clear.broadcast", true); } + public String getItemClearMessage() { return cfg.getString("emergency-actions.item-clear.broadcast-message", "&c[ServerPulse] Item-Clear in {seconds}s!"); } + public int getItemClearCountdown() { return cfg.getInt("emergency-actions.item-clear.countdown-seconds", 30); } + public boolean isMobClearEnabled() { return cfg.getBoolean("emergency-actions.mob-clear.enabled", false); } + public boolean isMobClearHostileOnly() { return cfg.getBoolean("emergency-actions.mob-clear.hostile-only", true); } + public boolean isAutoDiagnosisEnabled() { return cfg.getBoolean("emergency-actions.auto-diagnosis.enabled", true); } + public int getAutoDiagnosisTrigger() { return cfg.getInt("emergency-actions.auto-diagnosis.trigger-count", 3); } + public String getPrefix() { return MsgUtil.colorize(cfg.getString("messages.prefix", "&8[&bServerPulse&8] &r")); } + public String getMessage(String key) { return MsgUtil.colorize(cfg.getString("messages."+key, "")); } + public FileConfiguration getCfg() { return cfg; } +} diff --git a/src/main/java/de/serverpulse/utils/ConfigManager.java b/src/main/java/de/serverpulse/utils/ConfigManager.java new file mode 100644 index 0000000..12d6e88 --- /dev/null +++ b/src/main/java/de/serverpulse/utils/ConfigManager.java @@ -0,0 +1,347 @@ +package de.serverpulse.utils; + +import de.serverpulse.ServerPulse; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; + +/** + * Verwaltet alle Konfigurationsdateien von ServerPulse. + */ +public class ConfigManager { + + private final ServerPulse plugin; + private FileConfiguration config; + + public ConfigManager(ServerPulse plugin) { + this.plugin = plugin; + } + + /** + * Lädt alle Konfigurationsdateien + */ + public void loadAll() { + plugin.saveDefaultConfig(); + plugin.reloadConfig(); + this.config = plugin.getConfig(); + } + + // ────────────────────────────────────────── + // ALLGEMEINE EINSTELLUNGEN + // ────────────────────────────────────────── + + public boolean isDebugMode() { + return config.getBoolean("general.debug", false); + } + + public int getMetricsInterval() { + return Math.max(10, config.getInt("general.metrics-interval", 30)); + } + + public int getEntityInterval() { + return Math.max(10, config.getInt("general.entity-interval", 60)); + } + + public int getDataRetentionDays() { + return config.getInt("general.data-retention-days", 90); + } + + // ────────────────────────────────────────── + // DATENBANK + // ────────────────────────────────────────── + + public boolean isDatabaseEnabled() { + return config.getBoolean("database.enabled", true); + } + + public String getDbHost() { + return config.getString("database.host", "localhost"); + } + + public int getDbPort() { + return config.getInt("database.port", 3306); + } + + public String getDbName() { + return config.getString("database.database", "serverpulse"); + } + + public String getDbUsername() { + return config.getString("database.username", "root"); + } + + public String getDbPassword() { + return config.getString("database.password", ""); + } + + public int getPoolMaxSize() { + return config.getInt("database.pool.maximum-pool-size", 10); + } + + public int getPoolMinIdle() { + return config.getInt("database.pool.minimum-idle", 2); + } + + public long getPoolConnectionTimeout() { + return config.getLong("database.pool.connection-timeout", 30000); + } + + public long getPoolIdleTimeout() { + return config.getLong("database.pool.idle-timeout", 600000); + } + + public long getPoolMaxLifetime() { + return config.getLong("database.pool.max-lifetime", 1800000); + } + + // ────────────────────────────────────────── + // DISCORD + // ────────────────────────────────────────── + + public boolean isDiscordEnabled() { + return config.getBoolean("discord.enabled", false); + } + + public String getDiscordWebhookUrl() { + return config.getString("discord.webhook-url", ""); + } + + public String getDiscordReportWebhookUrl() { + String url = config.getString("discord.report-webhook-url", ""); + return (url == null || url.isEmpty()) ? getDiscordWebhookUrl() : url; + } + + public String getDiscordBotName() { + return config.getString("discord.bot-name", "ServerPulse"); + } + + public String getDiscordBotAvatar() { + return config.getString("discord.bot-avatar", ""); + } + + public boolean isDailyReportEnabled() { + return config.getBoolean("discord.daily-report.enabled", true) && isDiscordEnabled(); + } + + public String getDailyReportTime() { + return config.getString("discord.daily-report.time", "08:00"); + } + + public int getDiscordColorInfo() { + return config.getInt("discord.colors.info", 3447003); + } + + public int getDiscordColorWarning() { + return config.getInt("discord.colors.warning", 16776960); + } + + public int getDiscordColorCritical() { + return config.getInt("discord.colors.critical", 16711680); + } + + public int getDiscordColorSuccess() { + return config.getInt("discord.colors.success", 65280); + } + + // ────────────────────────────────────────── + // PERFORMANCE-GRENZWERTE + // ────────────────────────────────────────── + + public double getTpsWarning() { + return config.getDouble("thresholds.performance.tps-warning", 18.0); + } + + public double getTpsCritical() { + return config.getDouble("thresholds.performance.tps-critical", 15.0); + } + + public double getMsptWarning() { + return config.getDouble("thresholds.performance.mspt-warning", 40.0); + } + + public double getMsptCritical() { + return config.getDouble("thresholds.performance.mspt-critical", 50.0); + } + + public int getRamWarning() { + return config.getInt("thresholds.performance.ram-warning", 75); + } + + public int getRamCritical() { + return config.getInt("thresholds.performance.ram-critical", 90); + } + + // ────────────────────────────────────────── + // ENTITY-GRENZWERTE (global) + // ────────────────────────────────────────── + + public int getEntityTotalWarning() { + return config.getInt("thresholds.entities.total-warning", 1000); + } + + public int getEntityTotalCritical() { + return config.getInt("thresholds.entities.total-critical", 2000); + } + + public int getMonstersWarning() { + return config.getInt("thresholds.entities.monsters-warning", 300); + } + + public int getMonstersCritical() { + return config.getInt("thresholds.entities.monsters-critical", 600); + } + + public int getAnimalsWarning() { + return config.getInt("thresholds.entities.animals-warning", 200); + } + + public int getAnimalsCritical() { + return config.getInt("thresholds.entities.animals-critical", 400); + } + + public int getVillagersWarning() { + return config.getInt("thresholds.entities.villagers-warning", 100); + } + + public int getVillagersCritical() { + return config.getInt("thresholds.entities.villagers-critical", 200); + } + + public int getItemsWarning() { + return config.getInt("thresholds.entities.items-warning", 150); + } + + public int getItemsCritical() { + return config.getInt("thresholds.entities.items-critical", 300); + } + + // ────────────────────────────────────────── + // WELTBEZOGENE GRENZWERTE + // ────────────────────────────────────────── + + public int getWorldMonstersWarning(String worldName) { + String path = "worlds." + worldName + ".thresholds.monsters-warning"; + return config.getInt(path, getMonstersWarning()); + } + + public int getWorldMonstersCritical(String worldName) { + String path = "worlds." + worldName + ".thresholds.monsters-critical"; + return config.getInt(path, getMonstersCritical()); + } + + public int getWorldTotalWarning(String worldName) { + String path = "worlds." + worldName + ".thresholds.total-warning"; + return config.getInt(path, getEntityTotalWarning()); + } + + public int getWorldTotalCritical(String worldName) { + String path = "worlds." + worldName + ".thresholds.total-critical"; + return config.getInt(path, getEntityTotalCritical()); + } + + public String getWorldDisplayName(String worldName) { + String path = "worlds." + worldName + ".display-name"; + return config.getString(path, worldName); + } + + public boolean isWorldMonitored(String worldName) { + String path = "worlds." + worldName + ".enabled"; + // Wenn nicht explizit konfiguriert, wird die Welt standardmäßig überwacht + return config.getBoolean(path, true); + } + + // ────────────────────────────────────────── + // TREND-ANALYSE + // ────────────────────────────────────────── + + public boolean isTrendAnalysisEnabled() { + return config.getBoolean("trend-analysis.enabled", true); + } + + public int getTrendDataPoints() { + return config.getInt("trend-analysis.data-points", 10); + } + + public double getTrendIncreaseThreshold() { + return config.getDouble("trend-analysis.increase-threshold", 20.0); + } + + public int getTrendCheckInterval() { + return config.getInt("trend-analysis.check-interval", 5); + } + + // ────────────────────────────────────────── + // NOTFALLMASSNAHMEN + // ────────────────────────────────────────── + + public boolean isItemClearEnabled() { + return config.getBoolean("emergency-actions.item-clear.enabled", false); + } + + public boolean isItemClearBroadcast() { + return config.getBoolean("emergency-actions.item-clear.broadcast", true); + } + + public String getItemClearBroadcastMessage() { + return config.getString("emergency-actions.item-clear.broadcast-message", + "&c[ServerPulse] Automatischer Item-Clear in {seconds} Sekunden!"); + } + + public int getItemClearCountdown() { + return config.getInt("emergency-actions.item-clear.countdown-seconds", 30); + } + + public boolean isMobClearEnabled() { + return config.getBoolean("emergency-actions.mob-clear.enabled", false); + } + + public boolean isMobClearHostileOnly() { + return config.getBoolean("emergency-actions.mob-clear.hostile-only", true); + } + + public boolean isAutoDiagnosisEnabled() { + return config.getBoolean("emergency-actions.auto-diagnosis.enabled", true); + } + + public int getAutoDiagnosisTriggerCount() { + return config.getInt("emergency-actions.auto-diagnosis.trigger-count", 3); + } + + // ────────────────────────────────────────── + // REST API + // ────────────────────────────────────────── + + public boolean isRestApiEnabled() { + return config.getBoolean("rest-api.enabled", false); + } + + public int getRestApiPort() { + return config.getInt("rest-api.port", 8080); + } + + public String getRestApiHost() { + return config.getString("rest-api.host", "0.0.0.0"); + } + + public String getRestApiKey() { + return config.getString("rest-api.api-key", ""); + } + + // ────────────────────────────────────────── + // NACHRICHTEN + // ────────────────────────────────────────── + + public String getPrefix() { + return MessageUtil.colorize(config.getString("messages.prefix", "&8[&bServerPulse&8] &r")); + } + + public String getMessage(String key) { + return MessageUtil.colorize(config.getString("messages." + key, "")); + } + + public FileConfiguration getConfig() { + return config; + } +} diff --git a/src/main/java/de/serverpulse/utils/MessageUtil.java b/src/main/java/de/serverpulse/utils/MessageUtil.java new file mode 100644 index 0000000..65dceb9 --- /dev/null +++ b/src/main/java/de/serverpulse/utils/MessageUtil.java @@ -0,0 +1,133 @@ +package de.serverpulse.utils; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.command.CommandSender; + +import java.text.DecimalFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Hilfsmethoden für Nachrichten und Formatierung. + */ +public class MessageUtil { + + private static final DecimalFormat TPS_FORMAT = new DecimalFormat("##.##"); + private static final DecimalFormat MSPT_FORMAT = new DecimalFormat("##.##"); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"); + + private MessageUtil() {} + + /** + * Konvertiert Farb-Codes (&x) in Minecraft-Farben + */ + public static String colorize(String message) { + if (message == null) return ""; + return ChatColor.translateAlternateColorCodes('&', message); + } + + /** + * Sendet eine formatierte Nachricht an einen CommandSender + */ + public static void send(CommandSender sender, String message) { + sender.sendMessage(colorize(message)); + } + + /** + * Sendet eine Nachricht mit Prefix + */ + public static void sendWithPrefix(CommandSender sender, String prefix, String message) { + sender.sendMessage(colorize(prefix + message)); + } + + /** + * Formatiert TPS-Wert mit Farbe + */ + public static String formatTps(double tps) { + String formatted = TPS_FORMAT.format(Math.min(20.0, tps)); + if (tps >= 18.0) return "&a" + formatted; + if (tps >= 15.0) return "&e" + formatted; + return "&c" + formatted; + } + + /** + * Formatiert MSPT-Wert mit Farbe + */ + public static String formatMspt(double mspt) { + String formatted = MSPT_FORMAT.format(mspt) + "ms"; + if (mspt <= 40.0) return "&a" + formatted; + if (mspt <= 50.0) return "&e" + formatted; + return "&c" + formatted; + } + + /** + * Formatiert RAM-Auslastung mit Farbe + */ + public static String formatRam(long usedMB, long maxMB) { + double percent = (double) usedMB / maxMB * 100; + String formatted = usedMB + "MB / " + maxMB + "MB (" + (int) percent + "%)"; + if (percent <= 75) return "&a" + formatted; + if (percent <= 90) return "&e" + formatted; + return "&c" + formatted; + } + + /** + * Formatiert einen Entity-Wert gegen einen Grenzwert + */ + public static String formatEntityCount(int count, int warning, int critical) { + if (count >= critical) return "&c" + count; + if (count >= warning) return "&e" + count; + return "&a" + count; + } + + /** + * Erstellt eine visuelle Fortschrittsleiste + */ + public static String progressBar(double value, double max, int length) { + int filled = (int) Math.round((value / max) * length); + filled = Math.min(filled, length); + StringBuilder bar = new StringBuilder(); + + double percent = (value / max) * 100; + String color = percent <= 50 ? "&a" : (percent <= 75 ? "&e" : "&c"); + + bar.append(color); + for (int i = 0; i < filled; i++) bar.append("█"); + bar.append("&7"); + for (int i = filled; i < length; i++) bar.append("░"); + + return colorize(bar.toString()); + } + + /** + * Gibt aktuellen Timestamp zurück + */ + public static String getTimestamp() { + return LocalDateTime.now().format(DATE_FORMAT); + } + + /** + * Formatiert Bytes in lesbare Einheiten + */ + public static String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + + /** + * Erstellt eine Trennlinie + */ + public static String separator() { + return colorize("&8&m" + "─".repeat(50)); + } + + /** + * Erstellt einen Header + */ + public static String header(String title) { + int padding = (48 - title.length()) / 2; + return colorize("&8&m" + "─".repeat(padding) + "&r &b" + title + " &8&m" + "─".repeat(padding)); + } +} diff --git a/src/main/resources/bungee.yml b/src/main/resources/bungee.yml new file mode 100644 index 0000000..a864c1c --- /dev/null +++ b/src/main/resources/bungee.yml @@ -0,0 +1,12 @@ +name: ServerPulse +version: '1.0.0' +main: de.serverpulse.bungee.BungeePlugin +author: ServerPulse-Team +description: ServerPulse BungeeCord Module (unified JAR) + +commands: + bpulse: + description: ServerPulse BungeeCord monitoring + usage: /bpulse + aliases: [ bsp, networkpulse ] + permission: serverpulse.bungee.use diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..7b8d08e --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,169 @@ +# ╔══════════════════════════════════════════╗ +# ║ ServerPulse v1.0.0 Configuration ║ +# ║ Unified JAR – Spigot / Paper / Bungee ║ +# ╚══════════════════════════════════════════╝ +# +# Diese Datei gilt für BEIDE Seiten (Spigot + BungeeCord). +# Spigot-Server und BungeeCord-Proxy lesen ihre jeweiligen Abschnitte. + +# ────────────────────────────────────────── +# ALLGEMEIN +# ────────────────────────────────────────── +general: + debug: false + metrics-interval: 30 # Sekunden (Spigot: Performance-Erfassung) + entity-interval: 60 # Sekunden (Spigot: Entity-Erfassung) + poll-interval: 30 # Sekunden (BungeeCord: Daten-Weiterleitung) + data-retention-days: 90 # Tage (0 = unbegrenzt, nur Spigot) + +# ────────────────────────────────────────── +# MYSQL (nur Spigot-Seite) +# ────────────────────────────────────────── +database: + enabled: true + host: "localhost" + port: 3306 + database: "serverpulse" + username: "root" + password: "password" + pool: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + +# ────────────────────────────────────────── +# DISCORD (gilt für beide Seiten) +# ────────────────────────────────────────── +discord: + enabled: false + webhook-url: "https://discord.com/api/webhooks/YOUR_WEBHOOK" + report-webhook-url: "" + bot-name: "ServerPulse" + bot-avatar: "" + daily-report: + enabled: true + time: "08:00" + colors: + info: 3447003 + warning: 16776960 + critical: 16711680 + success: 65280 + +# ────────────────────────────────────────── +# GRENZWERTE +# ────────────────────────────────────────── +thresholds: + performance: + tps-warning: 18.0 + tps-critical: 15.0 + mspt-warning: 40.0 + mspt-critical: 50.0 + ram-warning: 75 + ram-critical: 90 + entities: + total-warning: 1000 + total-critical: 2000 + monsters-warning: 300 + monsters-critical: 600 + animals-warning: 200 + animals-critical: 400 + villagers-warning: 100 + villagers-critical: 200 + items-warning: 150 + items-critical: 300 + network: + players-warning: 80 # % des Slot-Limits + players-critical: 95 + +# ────────────────────────────────────────── +# WELTBEZOGENE KONFIGURATION (Spigot) +# ────────────────────────────────────────── +worlds: + # Beispiel: + # world: + # enabled: true + # display-name: "Überwelt" + # thresholds: + # monsters-warning: 400 + # monsters-critical: 800 + # total-warning: 1500 + # total-critical: 3000 + +# ────────────────────────────────────────── +# TREND-ANALYSE (Spigot) +# ────────────────────────────────────────── +trend-analysis: + enabled: true + data-points: 10 + increase-threshold: 20 + check-interval: 5 + +# ────────────────────────────────────────── +# NOTFALLMASSNAHMEN (Spigot) +# ────────────────────────────────────────── +emergency-actions: + item-clear: + enabled: false + broadcast: true + broadcast-message: "&c[ServerPulse] Automatischer Item-Clear in {seconds} Sekunden!" + countdown-seconds: 30 + mob-clear: + enabled: false + hostile-only: true + broadcast: true + broadcast-message: "&c[ServerPulse] Automatischer Mob-Clear wird ausgeführt!" + auto-diagnosis: + enabled: true + trigger-count: 3 + +# ────────────────────────────────────────── +# CHAT-MONITORING (BungeeCord) +# ────────────────────────────────────────── +chat-monitoring: + enabled: true + suspicious-patterns: + - "(?i)(hack|cheat|xray|fly|kill ?aura)" + - "(?i)(buy .{0,20}rank|cheap .{0,20}coins)" + spam: + enabled: true + messages-per-second: 5 + +# ────────────────────────────────────────── +# REST API / GRAFANA (BungeeCord) +# ────────────────────────────────────────── +rest-api: + enabled: false + host: "0.0.0.0" + port: 8081 + api-key: "YOUR_SECURE_API_KEY" + formats: + prometheus: + enabled: true + endpoint: "/metrics" + simple-json: + enabled: true + endpoint: "/grafana" + influxdb: + enabled: true + endpoint: "/influx" + measurement: "serverpulse" + push: + enabled: false + url: "http://localhost:8086" + token: "YOUR_INFLUX_TOKEN" + org: "YOUR_ORG" + bucket: "serverpulse" + json: + enabled: true + endpoint: "/api" + +# ────────────────────────────────────────── +# NACHRICHTEN (Spigot) +# ────────────────────────────────────────── +messages: + prefix: "&8[&bServerPulse&8] &r" + no-permission: "&cDu hast keine Berechtigung für diesen Befehl." + reload-success: "&aKonfiguration wurde erfolgreich neu geladen." + database-error: "&cDatenbankfehler! Bitte prüfe die Logs." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..24a9afe --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,28 @@ +name: ServerPulse +version: '1.0.0' +main: de.serverpulse.spigot.SpigotPlugin +api-version: '1.21' +description: Monitoring & Analytics Suite (Spigot/Paper/BungeeCord unified) +authors: [ M_Viper ] +prefix: ServerPulse + +commands: + serverpulse: + description: ServerPulse main command + usage: /serverpulse + aliases: [ pulse ] + permission: serverpulse.use + +permissions: + serverpulse.use: + default: op + serverpulse.admin: + default: op + children: + serverpulse.use: true + serverpulse.status: { default: op } + serverpulse.report: { default: op } + serverpulse.world: { default: op } + serverpulse.entities: { default: op } + serverpulse.reload: { default: op } + serverpulse.debug: { default: op }