Real-Time Packet Monitor Dashboard
Build a PurpurMC plugin that streams live packet metrics to a web dashboard.
Introduction
30 Second Summary
Every Minecraft server handles thousands of network packets per second. Players moving, blocks breaking, chat messages flying. Without visibility into this traffic, diagnosing lag spikes or suspicious connections is like debugging in the dark.
In this project, you will build a Kotlin plugin for PurpurMC that intercepts every packet flowing through your server, aggregates the data on a background thread, and streams live metrics to a browser-based dashboard over WebSocket.
What You'll Build
A real-time packet monitoring plugin with an embedded web dashboard that displays live network throughput, packet rates, player connections, and time-series charts using a terminal-themed interface.
By the end of this project, you'll have:
- A Kotlin PurpurMC plugin that intercepts all inbound and outbound packets using PacketEvents with zero main-thread overhead.
- An embedded WebSocket server inside the plugin that pushes JSON snapshots every 200ms to connected browser clients.
- A dark-themed Live Operations dashboard with real-time stat cards, canvas-based time-series charts, and a live player list.
- Secret Mission: Add a per-player detail view that shows individual packet type breakdowns when you click a player in the sidebar.
Are there any prerequisites?
This project assumes you have Kotlin proficiency, IntelliJ IDEA installed, Java 26 installed, and a running PurpurMC 1.21.4+ test server.
You do not need any web development experience. The dashboard is a single HTML file with inline CSS and JavaScript that you will paste directly into the plugin.
Set Up the Plugin Project
To monitor packets on your PurpurMC server, you need a plugin project that can hook into the server's networking layer.
In this step you will create a Gradle project with the Kotlin DSL, pull in PacketEvents for packet interception and Java-WebSocket for streaming data to a browser, then wire up the main plugin class.
In this step, get ready to:
- Configure a Gradle Kotlin DSL project with PacketEvents and Java-WebSocket dependencies.
- Create the plugin entry point with packet listener registration.
- Download PacketEvents and prepare your test server.
Create a new Gradle Kotlin DSL project in IntelliJ IDEA
Every PurpurMC plugin starts as a standard JVM project. IntelliJ IDEA's New Project wizard scaffolds the directory structure and build file for you.
- In IntelliJ IDEA, click New Project on the Welcome screen (or go to File then New then Project).
- From the list on the left, select Kotlin.
- Name the project PacketMonitor.
- Select Gradle as the build system and Kotlin for the Gradle DSL.
- Select your Java 26 JDK from the JDK dropdown.
- Disable the Add sample code option.
- Click Create.
IntelliJ IDEA generates a build.gradle.kts and the standard src/main/kotlin directory structure.
Why Gradle Kotlin DSL?
The Kotlin DSL gives you full IDE autocompletion on build logic. Since our plugin code is also Kotlin, keeping the build script in the same language reduces context-switching.
- Open build.gradle.kts in the editor and replace its entire contents with the following configuration:
plugins {
kotlin("jvm") version "2.1.0"
id("com.gradleup.shadow") version "9.0.0-beta4"
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.codemc.io/repository/maven-releases/")
}
dependencies {
// Paper API gives access to the Bukkit plugin lifecycle
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
// PacketEvents handles low-level packet interception
compileOnly("com.github.retrooper:packetevents-spigot:2.13.0")
// Java-WebSocket will stream metrics to the browser
implementation("org.java-websocket:Java-WebSocket:1.6.0")
}
kotlin {
jvmToolchain(21)
}
tasks.shadowJar {
// Produce a clean JAR name without "-all" suffix
archiveClassifier.set("")
// Relocate to avoid conflicts with other plugins
relocate("org.java_websocket", "com.example.libs.websocket")
}
tasks.build {
dependsOn(tasks.shadowJar)
}
What does this build file do?
- The Shadow plugin bundles Java-WebSocket into your final JAR so the server doesn't need it separately.
- compileOnly means Paper API and PacketEvents are provided at runtime by the server and don't get bundled.
- implementation means Java-WebSocket is NOT provided by the server, so it gets shaded into your plugin JAR.
- The relocate call moves Java-WebSocket's packages to a unique namespace, preventing classpath collisions with other plugins.
- Wait for IntelliJ IDEA to finish syncing the Gradle project. You should see the dependencies resolve in the Build output panel without errors.
Gradle sync failing?
Make sure you are connected to the internet. The Paper and CodeMC repositories are external Maven servers that Gradle needs to reach.
If you see a JDK mismatch error, open File then Project Structure and confirm the Project SDK is set to Java 26.
Why is my Gradle sync failing in IntelliJ IDEA for a Kotlin PurpurMC plugin project?
- Create the plugin descriptor by right-clicking the src/main/resources folder, selecting New then File, and naming it plugin.yml.
- Paste the following contents into plugin.yml:
name: PacketMonitor
version: "1.0.0"
main: com.example.packetmonitor.PacketMonitorPlugin
api-version: "1.21"
depend:
- packetevents
What does plugin.yml do?
This file tells the PurpurMC server about your plugin. The main field points to your entry-point class. The depend list ensures PacketEvents loads before your plugin so its API is available in onLoad().
- Save plugin.yml.
Create the main plugin class
The plugin class is the entry point that the server calls when your plugin loads, enables, and disables. You will wire up PacketEvents listener registration in onLoad() and cleanup in onDisable().
- Create the package directory by right-clicking src/main/kotlin, selecting New then Package, and typing com.example.packetmonitor.
- Right-click the new packetmonitor package, select New then Kotlin Class/File, and name it PacketMonitorPlugin.
- Replace the generated file contents with the following code:
package com.example.packetmonitor
import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import org.bukkit.plugin.java.JavaPlugin
import java.net.InetSocketAddress
class PacketMonitorPlugin : JavaPlugin() {
private lateinit var webSocketServer: DashboardWebSocket
private lateinit var snapshotEngine: SnapshotEngine
override fun onLoad() {
// Register our packet listener at MONITOR priority (read-only, no modifications)
val eventManager = PacketEvents.getAPI().eventManager
eventManager.registerListener(PacketMonitorListener(), PacketListenerPriority.MONITOR)
}
override fun onEnable() {
// Start the WebSocket server on a configurable port
val port = config.getInt("dashboard-port", 8099)
val address = InetSocketAddress("0.0.0.0", port)
webSocketServer = DashboardWebSocket(address, this)
webSocketServer.isReuseAddr = true
webSocketServer.start()
// Start the snapshot engine that broadcasts metrics
snapshotEngine = SnapshotEngine(webSocketServer, this)
snapshotEngine.start()
// Update player health from the main thread once per second
server.scheduler.runTaskTimer(this, Runnable {
MetricsCollector.updatePlayerHealth(server)
}, 20L, 20L)
logger.info("PacketMonitor dashboard running on http://localhost:$port")
}
override fun onDisable() {
// Clean shutdown of background services
snapshotEngine.stop()
webSocketServer.stop(1000)
}
}
What does this class do?
- onLoad() registers the packet listener with PacketEvents at MONITOR priority. This means the listener observes packets without modifying them.
- onEnable() starts the WebSocket server and snapshot engine that we will build in later steps.
- onDisable() gracefully shuts down both services when the server stops.
- The lateinit declarations reference classes (DashboardWebSocket, SnapshotEngine, PacketMonitorListener, MetricsCollector) that you will create in the next steps. IntelliJ will show red underlines until those classes exist.
- Save PacketMonitorPlugin.kt.
Expect unresolved references
IntelliJ IDEA will highlight DashboardWebSocket, SnapshotEngine, PacketMonitorListener, and MetricsCollector in red. That is normal. You will create these classes in Steps 2, 3, and 4.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both build.gradle.kts and PacketMonitorPlugin.kt.
ⓧ I'd like to double check the full code
plugins {
kotlin("jvm") version "2.1.0"
id("com.gradleup.shadow") version "9.0.0-beta4"
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.codemc.io/repository/maven-releases/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
compileOnly("com.github.retrooper:packetevents-spigot:2.13.0")
implementation("org.java-websocket:Java-WebSocket:1.6.0")
}
kotlin {
jvmToolchain(21)
}
tasks.shadowJar {
archiveClassifier.set("")
relocate("org.java_websocket", "com.example.libs.websocket")
}
tasks.build {
dependsOn(tasks.shadowJar)
}
name: PacketMonitor
version: "1.0.0"
main: com.example.packetmonitor.PacketMonitorPlugin
api-version: "1.21"
depend:
- packetevents
package com.example.packetmonitor
import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import org.bukkit.plugin.java.JavaPlugin
import java.net.InetSocketAddress
class PacketMonitorPlugin : JavaPlugin() {
private lateinit var webSocketServer: DashboardWebSocket
private lateinit var snapshotEngine: SnapshotEngine
override fun onLoad() {
val eventManager = PacketEvents.getAPI().eventManager
eventManager.registerListener(PacketMonitorListener(), PacketListenerPriority.MONITOR)
}
override fun onEnable() {
val port = config.getInt("dashboard-port", 8099)
val address = InetSocketAddress("0.0.0.0", port)
webSocketServer = DashboardWebSocket(address, this)
webSocketServer.isReuseAddr = true
webSocketServer.start()
snapshotEngine = SnapshotEngine(webSocketServer, this)
snapshotEngine.start()
server.scheduler.runTaskTimer(this, Runnable {
MetricsCollector.updatePlayerHealth(server)
}, 20L, 20L)
logger.info("PacketMonitor dashboard running on http://localhost:$port")
}
override fun onDisable() {
snapshotEngine.stop()
webSocketServer.stop(1000)
}
}
Download PacketEvents and prepare your test server
PacketEvents is a separate plugin that your server needs at runtime. Your project declares it as compileOnly (for IDE autocompletion), but the actual JAR must sit in the server's plugins/ folder.
- Open https://modrinth.com/plugin/packetevents/versions in your browser.
- Find version 2.13.0 with the Spigot loader and click Download on the primary JAR file (packetevents-spigot-2.13.0.jar).
- Copy the downloaded JAR into your PurpurMC test server's plugins/ folder.
Why a separate JAR?
PacketEvents intercepts packets at the Netty level and needs to load before any plugin that uses it. Declaring it in depend inside plugin.yml guarantees load order. The server itself hosts the PacketEvents JAR, and your plugin calls its API at runtime.
Your plugin project won't compile yet because the classes it references (PacketMonitorListener, DashboardWebSocket, etc.) do not exist. You will create them in the next steps, and then build and deploy your JAR to the server.
Can't find PacketEvents 2.13.0 on Modrinth?
Make sure you are on the Versions tab of the PacketEvents Modrinth page. Filter by the Spigot loader if available. The file name should start with packetevents-spigot-.
Help me find and download PacketEvents 2.13.0 for my PurpurMC server.
Your project skeleton is in place. Next up, you will create the packet listener and metrics collector that give your plugin the ability to see every byte flowing through the server.
Build the Packet Interception Layer
Your plugin skeleton loads on the server, but it cannot see any network traffic yet. Every player action in Minecraft (moving, breaking blocks, chatting) travels as a packet between the client and server.
In this step, you will build the data layer that counts every packet flowing through the server. You will use PacketEvents to intercept packets on the Netty I/O thread and atomic counters to record metrics without touching the main server tick.
In this step, get ready to:
- Create a MetricsCollector that tracks global and per-player packet stats using thread-safe counters.
- Create a PacketMonitorListener that wires PacketEvents into the MetricsCollector.
- Verify the interception works by joining your test server and checking logged metrics.
Create the MetricsCollector object
Before the listener can record anything, it needs somewhere to store the data. The MetricsCollector is a Kotlin object (singleton) that holds global counters and a per-player map.
Why AtomicLong instead of a regular Long?
PacketEvents listeners run on Netty I/O threads, not the main server thread. Multiple threads can increment the same counter simultaneously.
AtomicLong uses hardware-level compare-and-swap (CAS) operations so threads never block each other. This gives you zero-overhead counting with no locks.
- Create a new Kotlin file at src/main/kotlin/com/example/packetmonitor/MetricsCollector.kt by right-clicking the packetmonitor package in IntelliJ's Project panel, selecting New then Kotlin Class/File.
- Name it MetricsCollector and select Object as the kind.
- Replace the generated content with the MetricsCollector object below:
package com.example.packetmonitor
import org.bukkit.Server
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
object MetricsCollector {
// Global counters for all packets across all players
val totalPacketsIn = AtomicLong(0)
val totalPacketsOut = AtomicLong(0)
val totalBytesIn = AtomicLong(0)
val totalBytesOut = AtomicLong(0)
// Per-player metrics keyed by UUID
val players = ConcurrentHashMap<UUID, PlayerMetrics>()
fun recordInbound(uuid: UUID, bytes: Long) {
totalPacketsIn.incrementAndGet()
totalBytesIn.addAndGet(bytes)
players[uuid]?.let {
it.packetsIn.incrementAndGet()
it.bytesIn.addAndGet(bytes)
}
}
fun recordOutbound(uuid: UUID, bytes: Long) {
totalPacketsOut.incrementAndGet()
totalBytesOut.addAndGet(bytes)
players[uuid]?.let {
it.packetsOut.incrementAndGet()
it.bytesOut.addAndGet(bytes)
}
}
fun registerPlayer(uuid: UUID, name: String, ip: String, clientVersion: String) {
players[uuid] = PlayerMetrics(
uuid = uuid,
name = name,
ip = ip,
clientVersion = clientVersion
)
}
fun unregisterPlayer(uuid: UUID) {
players.remove(uuid)
}
fun updatePlayerHealth(server: Server) {
for ((uuid, metrics) in players) {
val player = server.getPlayer(uuid)
if (player != null) {
metrics.health = player.health
}
}
}
}
What does this code do?
- Four AtomicLong fields track global inbound/outbound packet counts and byte totals.
- A ConcurrentHashMap keyed by player UUID holds per-player metrics so multiple Netty threads can write simultaneously.
- recordInbound() and recordOutbound() increment both global and per-player counters atomically.
- registerPlayer() and unregisterPlayer() manage the player map as connections open and close.
- updatePlayerHealth() syncs the Bukkit health value from the main thread (called by the existing scheduled task in your plugin class).
Now add the PlayerMetrics class at the bottom of the same file, below the closing brace of the MetricsCollector object:
class PlayerMetrics(
val uuid: UUID,
val name: String,
val ip: String,
val clientVersion: String,
var health: Double = 20.0,
val packetsIn: AtomicLong = AtomicLong(0),
val packetsOut: AtomicLong = AtomicLong(0),
val bytesIn: AtomicLong = AtomicLong(0),
val bytesOut: AtomicLong = AtomicLong(0)
)
What does PlayerMetrics hold?
Each PlayerMetrics instance stores identity info (UUID, name, IP, client version) alongside individual AtomicLong counters for that player's packets and bytes.
The health field is a mutable var because it gets refreshed once per second from the Bukkit scheduler on the main thread. The default value of 20.0 represents full health in Minecraft.
- Save MetricsCollector.kt.
You will not see a visible change yet. The next chunk creates the listener that feeds data into these counters.
Seeing red import errors in IntelliJ?
- Make sure your Gradle project is synced. Click the elephant icon in the Gradle tool window or press Ctrl+Shift+O (Windows) / Cmd+Shift+I (macOS) to reimport.
- Confirm paper-api is listed as a compileOnly dependency in build.gradle.kts. The Server import comes from that dependency.
Ask for help: My MetricsCollector.kt has red imports for Server or AtomicLong. How do I fix this?
✔️ Awesome, I've got everything!
Great. Double check you have saved MetricsCollector.kt before moving on.
ⓧ I'd like to double check the full code
package com.example.packetmonitor
import org.bukkit.Server
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
object MetricsCollector {
val totalPacketsIn = AtomicLong(0)
val totalPacketsOut = AtomicLong(0)
val totalBytesIn = AtomicLong(0)
val totalBytesOut = AtomicLong(0)
val players = ConcurrentHashMap<UUID, PlayerMetrics>()
fun recordInbound(uuid: UUID, bytes: Long) {
totalPacketsIn.incrementAndGet()
totalBytesIn.addAndGet(bytes)
players[uuid]?.let {
it.packetsIn.incrementAndGet()
it.bytesIn.addAndGet(bytes)
}
}
fun recordOutbound(uuid: UUID, bytes: Long) {
totalPacketsOut.incrementAndGet()
totalBytesOut.addAndGet(bytes)
players[uuid]?.let {
it.packetsOut.incrementAndGet()
it.bytesOut.addAndGet(bytes)
}
}
fun registerPlayer(uuid: UUID, name: String, ip: String, clientVersion: String) {
players[uuid] = PlayerMetrics(
uuid = uuid,
name = name,
ip = ip,
clientVersion = clientVersion
)
}
fun unregisterPlayer(uuid: UUID) {
players.remove(uuid)
}
fun updatePlayerHealth(server: Server) {
for ((uuid, metrics) in players) {
val player = server.getPlayer(uuid)
if (player != null) {
metrics.health = player.health
}
}
}
}
class PlayerMetrics(
val uuid: UUID,
val name: String,
val ip: String,
val clientVersion: String,
var health: Double = 20.0,
val packetsIn: AtomicLong = AtomicLong(0),
val packetsOut: AtomicLong = AtomicLong(0),
val bytesIn: AtomicLong = AtomicLong(0),
val bytesOut: AtomicLong = AtomicLong(0)
)
Create the PacketMonitorListener
The listener is where PacketEvents hands you every packet entering or leaving the server. It runs on the Netty I/O thread, so the work here must be fast and non-blocking.
- Create a new Kotlin file at src/main/kotlin/com/example/packetmonitor/PacketMonitorListener.kt in the same packetmonitor package.
- Paste the following code:
package com.example.packetmonitor
import com.github.retrooper.packetevents.event.PacketListener
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent
import com.github.retrooper.packetevents.event.UserConnectEvent
import com.github.retrooper.packetevents.event.UserDisconnectEvent
class PacketMonitorListener : PacketListener {
override fun onPacketReceive(event: PacketReceiveEvent) {
// Guard against null user during early login race conditions
val user = event.getUser() ?: return
val uuid = user.uuid ?: return
val size = event.byteBuf?.readableBytes()?.toLong() ?: 0L
MetricsCollector.recordInbound(uuid, size)
}
override fun onPacketSend(event: PacketSendEvent) {
val user = event.getUser() ?: return
val uuid = user.uuid ?: return
val size = event.byteBuf?.readableBytes()?.toLong() ?: 0L
MetricsCollector.recordOutbound(uuid, size)
}
override fun onUserConnect(event: UserConnectEvent) {
val user = event.getUser()
val uuid = user.uuid ?: return
val address = user.address?.address?.hostAddress ?: "unknown"
val version = user.clientVersion?.releaseName ?: "unknown"
MetricsCollector.registerPlayer(uuid, user.name ?: "unknown", address, version)
}
override fun onUserDisconnect(event: UserDisconnectEvent) {
val user = event.getUser()
val uuid = user.uuid ?: return
MetricsCollector.unregisterPlayer(uuid)
}
}
What does this code do?
- onPacketReceive() fires for every inbound packet (client to server). It grabs the ByteBuf size and delegates to MetricsCollector.recordInbound().
- onPacketSend() fires for every outbound packet (server to client). Same pattern, calling recordOutbound().
- onUserConnect() registers a new player in the metrics map with their UUID, name, IP, and protocol version.
- onUserDisconnect() removes the player from tracking when they leave.
- The ?: return null guards protect against login race conditions where the User object is not yet fully resolved.
- Save PacketMonitorListener.kt.
Your plugin class already instantiates this listener in onLoad() and registers it with PacketEvents at MONITOR priority. No changes needed there.
Cannot resolve PacketEvents imports?
- Confirm compileOnly("com.github.retrooper:packetevents-spigot:2.13.0") is in your build.gradle.kts dependencies block.
- Confirm the CodeMC repository maven("https://repo.codemc.io/repository/maven-releases/") is listed in the repositories block.
- Re-sync Gradle by clicking the elephant icon in the Gradle tool window.
Ask for help: IntelliJ cannot resolve PacketEvents imports in my listener. How do I fix this?
✔️ Awesome, I've got everything!
Great. Make sure PacketMonitorListener.kt is saved before continuing.
ⓧ I'd like to double check the full code
package com.example.packetmonitor
import com.github.retrooper.packetevents.event.PacketListener
import com.github.retrooper.packetevents.event.PacketReceiveEvent
import com.github.retrooper.packetevents.event.PacketSendEvent
import com.github.retrooper.packetevents.event.UserConnectEvent
import com.github.retrooper.packetevents.event.UserDisconnectEvent
class PacketMonitorListener : PacketListener {
override fun onPacketReceive(event: PacketReceiveEvent) {
val user = event.getUser() ?: return
val uuid = user.uuid ?: return
val size = event.byteBuf?.readableBytes()?.toLong() ?: 0L
MetricsCollector.recordInbound(uuid, size)
}
override fun onPacketSend(event: PacketSendEvent) {
val user = event.getUser() ?: return
val uuid = user.uuid ?: return
val size = event.byteBuf?.readableBytes()?.toLong() ?: 0L
MetricsCollector.recordOutbound(uuid, size)
}
override fun onUserConnect(event: UserConnectEvent) {
val user = event.getUser()
val uuid = user.uuid ?: return
val address = user.address?.address?.hostAddress ?: "unknown"
val version = user.clientVersion?.releaseName ?: "unknown"
MetricsCollector.registerPlayer(uuid, user.name ?: "unknown", address, version)
}
override fun onUserDisconnect(event: UserDisconnectEvent) {
val user = event.getUser()
val uuid = user.uuid ?: return
MetricsCollector.unregisterPlayer(uuid)
}
}
Verify packet interception on your test server
The plugin class references DashboardWebSocket and SnapshotEngine which do not exist yet. You need to temporarily comment those out so the project compiles for testing.
- In PacketMonitorPlugin.kt, comment out the lateinit declarations for webSocketServer and snapshotEngine, and everything inside onEnable() and onDisable().
- Add a temporary logging task inside onEnable() so you can see counters ticking. Paste this temporary code:
// Temporary verification - remove after confirming metrics work
server.scheduler.runTaskTimer(this, Runnable {
logger.info("[Metrics] IN: ${MetricsCollector.totalPacketsIn.get()} pkts / ${MetricsCollector.totalBytesIn.get()} bytes | OUT: ${MetricsCollector.totalPacketsOut.get()} pkts / ${MetricsCollector.totalBytesOut.get()} bytes | Players: ${MetricsCollector.players.size}")
}, 100L, 100L)
- Build the plugin JAR by running this in the IntelliJ terminal:
./gradlew build
- Copy the output JAR from build/libs/ to your test server's plugins/ folder.
- Restart your PurpurMC test server.
- Join the server with your Minecraft client.
- Watch the server console. Every 5 seconds you should see a log line showing incrementing packet and byte counts.
What should I see?
The console should print lines like:
[PacketMonitor] [Metrics] IN: 847 pkts / 23104 bytes | OUT: 1203 pkts / 95210 bytes | Players: 1
The numbers increase continuously as packets flow. Moving around or loading chunks causes a visible spike in the OUT count.
- Once verified, stop the server and revert your temporary changes in PacketMonitorPlugin.kt.
- Uncomment the lateinit declarations and the full onEnable() / onDisable() bodies, and remove the temporary logging task.
Not seeing any log output?
- Make sure the PacketEvents JAR (packetevents-spigot-2.13.0.jar) is in your server's plugins/ folder alongside your plugin JAR.
- Check that plugin.yml has depend: [packetevents]. Without this, your plugin loads before PacketEvents is ready.
- Look for errors above the metrics log line. If you see a NullPointerException, confirm your null guards (?: return) match the code shown above.
Ask for help: My PacketMonitor plugin is not printing metric logs after I join the server. How do I debug this?
Your interception layer is capturing every packet without touching the main thread. Next up, you will build the WebSocket server and snapshot engine that pushes these metrics to a live dashboard.
Build the WebSocket Server and Snapshot Engine
Your packet counters are incrementing every time data flows through the server. The problem is that these numbers are locked inside server memory with no way to see them in real time.
In this step, you will build the real-time delivery system. A WebSocket server embedded inside your plugin will push metric snapshots to any connected browser every 200ms. A snapshot engine running on its own background thread reads the atomic counters, computes per-second rates, and maintains a ring buffer so dashboards get immediate chart history on connect.
In this step, get ready to:
- Create the WebSocket server class that handles browser connections.
- Build the snapshot engine that aggregates metrics and broadcasts JSON every 200ms.
- Verify that a WebSocket client receives live JSON snapshots.
Create the WebSocket server class
The WebSocket server listens on a configurable port and handles the lifecycle of browser connections. It also prepares to serve the dashboard HTML file directly when you create it in the next step.
- In IntelliJ, right-click the com.example.packetmonitor package and select New then Kotlin Class/File.
- Name the file DashboardWebSocket and press Enter.
- Replace the file contents with the following code:
package com.example.packetmonitor
import org.bukkit.plugin.java.JavaPlugin
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.nio.ByteBuffer
class DashboardWebSocket(
address: InetSocketAddress,
private val plugin: JavaPlugin
) : WebSocketServer(address) {
// Lazy-load the dashboard HTML from the plugin JAR resources
private val dashboardHtml: String by lazy {
plugin.getResource("dashboard.html")?.bufferedReader()?.readText() ?: "<h1>Dashboard not found</h1>"
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
// Serve HTML if the client requests the root path
val path = handshake.resourceDescriptor
if (path == "/" || path == "/index.html") {
val response = buildString {
append("HTTP/1.1 200 OK\r\n")
append("Content-Type: text/html; charset=utf-8\r\n")
append("Content-Length: ${dashboardHtml.toByteArray().size}\r\n")
append("Connection: close\r\n")
append("\r\n")
append(dashboardHtml)
}
conn.send(response)
conn.close()
return
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {}
override fun onMessage(conn: WebSocket, message: String) {}
override fun onMessage(conn: WebSocket, message: ByteBuffer) {}
override fun onError(conn: WebSocket?, ex: Exception) {
plugin.logger.warning("WebSocket error: ${ex.message}")
}
override fun onStart() {}
}
What does this code do?
This class extends WebSocketServer from the Java-WebSocket library. It runs on its own thread and manages all browser connections automatically.
- The onOpen handler checks if the browser requested the root path. If so, it serves the dashboard HTML and closes the connection. Otherwise, it keeps the connection open for WebSocket streaming.
- The dashboardHtml property lazy-loads the HTML file from the plugin JAR. Until the file exists, it returns a fallback message.
- The inherited broadcast() method sends a string to all connected WebSocket clients. The connections property tracks active connections. The snapshot engine will use both of these.
- Save DashboardWebSocket.kt.
Create the snapshot engine with ring buffer
The snapshot engine is the heartbeat of your monitoring system. Every 200ms it reads the atomic counters, computes deltas to get per-second rates, and assembles a JSON snapshot that gets broadcast to all connected dashboards.
- Create another Kotlin file in the same package named SnapshotEngine.
- Replace the file contents with the following code:
package com.example.packetmonitor
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
class SnapshotEngine(
private val webSocket: DashboardWebSocket,
private val plugin: JavaPlugin
) {
// Single daemon thread dedicated to snapshot computation
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "PacketMonitor-Snapshot").apply { isDaemon = true }
}
// Ring buffer: 150 entries at 200ms = 30 seconds of history
private val historySize = 150
private val throughputHistory = ArrayDeque<Pair<Long, Long>>(historySize)
private val packetRateHistory = ArrayDeque<Pair<Long, Long>>(historySize)
// Tracks previous counter values for delta computation
private var lastBytesIn = 0L
private var lastBytesOut = 0L
private var lastPacketsIn = 0L
private var lastPacketsOut = 0L
private val startTime = System.currentTimeMillis()
private val serverId = "server-1"
fun start() {
// Snapshot the current counter values as the baseline
lastBytesIn = MetricsCollector.totalBytesIn.get()
lastBytesOut = MetricsCollector.totalBytesOut.get()
lastPacketsIn = MetricsCollector.totalPacketsIn.get()
lastPacketsOut = MetricsCollector.totalPacketsOut.get()
executor.scheduleAtFixedRate(::tick, 200, 200, TimeUnit.MILLISECONDS)
}
fun stop() {
executor.shutdownNow()
}
private fun tick() {
try {
// Skip work if no dashboards are connected
if (webSocket.connections.isEmpty()) return
val currentBytesIn = MetricsCollector.totalBytesIn.get()
val currentBytesOut = MetricsCollector.totalBytesOut.get()
val currentPacketsIn = MetricsCollector.totalPacketsIn.get()
val currentPacketsOut = MetricsCollector.totalPacketsOut.get()
// Multiply by 5 to convert 200ms delta into per-second rate
val deltaBytesIn = (currentBytesIn - lastBytesIn) * 5
val deltaBytesOut = (currentBytesOut - lastBytesOut) * 5
val deltaPacketsIn = (currentPacketsIn - lastPacketsIn) * 5
val deltaPacketsOut = (currentPacketsOut - lastPacketsOut) * 5
lastBytesIn = currentBytesIn
lastBytesOut = currentBytesOut
lastPacketsIn = currentPacketsIn
lastPacketsOut = currentPacketsOut
// Maintain ring buffer for throughput history
if (throughputHistory.size >= historySize) throughputHistory.removeFirst()
throughputHistory.addLast(deltaBytesIn to deltaBytesOut)
// Maintain ring buffer for packet rate history
if (packetRateHistory.size >= historySize) packetRateHistory.removeFirst()
packetRateHistory.addLast(deltaPacketsIn to deltaPacketsOut)
val uptimeSeconds = (System.currentTimeMillis() - startTime) / 1000
val playerCount = MetricsCollector.players.size
// Build per-player JSON array
val playersJson = MetricsCollector.players.values.joinToString(",") { p ->
val pktRate = (p.packetsIn.get() + p.packetsOut.get())
"""{"uuid":"${p.uuid}","name":"${p.name}","ip":"${p.ip}","version":"${p.clientVersion}","health":${p.health},"packetsIn":${p.packetsIn.get()},"packetsOut":${p.packetsOut.get()},"bytesIn":${p.bytesIn.get()},"bytesOut":${p.bytesOut.get()}}"""
}
// Serialize history arrays for chart rendering
val throughputJson = throughputHistory.joinToString(",") { "[${it.first},${it.second}]" }
val packetRateJson = packetRateHistory.joinToString(",") { "[${it.first},${it.second}]" }
// Assemble full snapshot with serverId for future multi-server support
val json = """{"serverId":"$serverId","uptime":$uptimeSeconds,"connections":$playerCount,"maxConnections":256,"bytesInPerSec":$deltaBytesIn,"bytesOutPerSec":$deltaBytesOut,"packetsInPerSec":$deltaPacketsIn,"packetsOutPerSec":$deltaPacketsOut,"players":[$playersJson],"throughputHistory":[$throughputJson],"packetRateHistory":[$packetRateJson]}"""
webSocket.broadcast(json)
} catch (e: Exception) {
plugin.logger.warning("Snapshot error: ${e.message}")
}
}
}
What does this code do?
The SnapshotEngine reads your atomic counters and turns raw totals into real-time rates that a dashboard can display.
- A ScheduledExecutorService fires tick() every 200ms on a single daemon thread. This thread never touches the main server thread.
- Delta computation subtracts the previous snapshot's counter values from the current ones. Multiplying by 5 converts a 200ms sample into a per-second rate (1000ms / 200ms = 5).
- Two ring buffers (throughputHistory and packetRateHistory) store the last 150 data points. At 200ms intervals, that is 30 seconds of chart history. New dashboards receive this full history on their first snapshot, so charts render immediately.
- The serverId field is baked into every snapshot. This prepares the data model for future multi-server expansion via Velocity without changing the JSON schema.
Why a ring buffer instead of a growing list?
A ring buffer caps memory usage at exactly 150 entries regardless of how long the server runs. Without it, your history array would grow indefinitely and eventually consume all available heap.
The ArrayDeque acts as the ring buffer here. When it reaches historySize (150), the engine removes the oldest entry before adding a new one.
- Save SnapshotEngine.kt.
Seeing a compilation error?
If IntelliJ shows red underlines on DashboardWebSocket or SnapshotEngine in PacketMonitorPlugin.kt, make sure both new files are saved and that IntelliJ has finished indexing. You can trigger a re-index by clicking File then Invalidate Caches / Restart.
If the error is on the Java-WebSocket import, verify your build.gradle.kts has implementation("org.java-websocket:Java-WebSocket:1.6.0") in dependencies and click the Gradle sync icon.
Help me fix a compilation error in my PacketMonitor plugin.
Verify WebSocket delivery
Time to confirm the full pipeline works end-to-end. You will build the plugin, deploy it, and connect to the WebSocket from your browser console.
- Build the shadow JAR by running this in the IntelliJ terminal:
./gradlew build
- Copy the output JAR from build/libs/ to your test server's plugins/ folder, replacing the old JAR.
- Restart your PurpurMC test server.
- Confirm the startup log shows:
PacketMonitor dashboard running on http://localhost:8099
- Open your browser and press Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to open the browser console.
- Connect to the WebSocket by running this in the console:
const ws = new WebSocket("ws://localhost:8099");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
- You should see JSON objects appearing in the console every 200ms. Each object contains serverId, uptime, connections, bytesInPerSec, packetsInPerSec, a players array, and the throughputHistory and packetRateHistory arrays.
What should I see?
If no players are connected to the Minecraft server, the connections field will be 0 and the players array will be empty. Join with your Minecraft client and you will see the player appear in the array and the rate values jump.
The throughputHistory array grows with each tick (one new entry every 200ms) until it reaches 150 entries. After that, old entries drop off and the array stays at a fixed length.
Not receiving any messages?
Check that port 8099 is not blocked by another process. Run lsof -i :8099 (macOS) or netstat -ano | findstr 8099 (Windows) to verify the server is listening.
If you see a connection open then immediately close, the server might be treating your WebSocket client as an HTTP request. Make sure your browser console code uses ws://localhost:8099 without a trailing slash. The onOpen handler only intercepts / and /index.html paths.
Help me debug why my WebSocket is not receiving snapshots.
✔️ Awesome, I've got everything!
Great. Double check that both DashboardWebSocket.kt and SnapshotEngine.kt are saved.
ⓧ I'd like to double check the full code
Here is the complete DashboardWebSocket.kt:
package com.example.packetmonitor
import org.bukkit.plugin.java.JavaPlugin
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.nio.ByteBuffer
class DashboardWebSocket(
address: InetSocketAddress,
private val plugin: JavaPlugin
) : WebSocketServer(address) {
private val dashboardHtml: String by lazy {
plugin.getResource("dashboard.html")?.bufferedReader()?.readText() ?: "<h1>Dashboard not found</h1>"
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val path = handshake.resourceDescriptor
if (path == "/" || path == "/index.html") {
val response = buildString {
append("HTTP/1.1 200 OK\r\n")
append("Content-Type: text/html; charset=utf-8\r\n")
append("Content-Length: ${dashboardHtml.toByteArray().size}\r\n")
append("Connection: close\r\n")
append("\r\n")
append(dashboardHtml)
}
conn.send(response)
conn.close()
return
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {}
override fun onMessage(conn: WebSocket, message: String) {}
override fun onMessage(conn: WebSocket, message: ByteBuffer) {}
override fun onError(conn: WebSocket?, ex: Exception) {
plugin.logger.warning("WebSocket error: ${ex.message}")
}
override fun onStart() {}
}
Here is the complete SnapshotEngine.kt:
package com.example.packetmonitor
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
class SnapshotEngine(
private val webSocket: DashboardWebSocket,
private val plugin: JavaPlugin
) {
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "PacketMonitor-Snapshot").apply { isDaemon = true }
}
private val historySize = 150
private val throughputHistory = ArrayDeque<Pair<Long, Long>>(historySize)
private val packetRateHistory = ArrayDeque<Pair<Long, Long>>(historySize)
private var lastBytesIn = 0L
private var lastBytesOut = 0L
private var lastPacketsIn = 0L
private var lastPacketsOut = 0L
private val startTime = System.currentTimeMillis()
private val serverId = "server-1"
fun start() {
lastBytesIn = MetricsCollector.totalBytesIn.get()
lastBytesOut = MetricsCollector.totalBytesOut.get()
lastPacketsIn = MetricsCollector.totalPacketsIn.get()
lastPacketsOut = MetricsCollector.totalPacketsOut.get()
executor.scheduleAtFixedRate(::tick, 200, 200, TimeUnit.MILLISECONDS)
}
fun stop() {
executor.shutdownNow()
}
private fun tick() {
try {
if (webSocket.connections.isEmpty()) return
val currentBytesIn = MetricsCollector.totalBytesIn.get()
val currentBytesOut = MetricsCollector.totalBytesOut.get()
val currentPacketsIn = MetricsCollector.totalPacketsIn.get()
val currentPacketsOut = MetricsCollector.totalPacketsOut.get()
val deltaBytesIn = (currentBytesIn - lastBytesIn) * 5
val deltaBytesOut = (currentBytesOut - lastBytesOut) * 5
val deltaPacketsIn = (currentPacketsIn - lastPacketsIn) * 5
val deltaPacketsOut = (currentPacketsOut - lastPacketsOut) * 5
lastBytesIn = currentBytesIn
lastBytesOut = currentBytesOut
lastPacketsIn = currentPacketsIn
lastPacketsOut = currentPacketsOut
if (throughputHistory.size >= historySize) throughputHistory.removeFirst()
throughputHistory.addLast(deltaBytesIn to deltaBytesOut)
if (packetRateHistory.size >= historySize) packetRateHistory.removeFirst()
packetRateHistory.addLast(deltaPacketsIn to deltaPacketsOut)
val uptimeSeconds = (System.currentTimeMillis() - startTime) / 1000
val playerCount = MetricsCollector.players.size
val playersJson = MetricsCollector.players.values.joinToString(",") { p ->
val pktRate = (p.packetsIn.get() + p.packetsOut.get())
"""{"uuid":"${p.uuid}","name":"${p.name}","ip":"${p.ip}","version":"${p.clientVersion}","health":${p.health},"packetsIn":${p.packetsIn.get()},"packetsOut":${p.packetsOut.get()},"bytesIn":${p.bytesIn.get()},"bytesOut":${p.bytesOut.get()}}"""
}
val throughputJson = throughputHistory.joinToString(",") { "[${it.first},${it.second}]" }
val packetRateJson = packetRateHistory.joinToString(",") { "[${it.first},${it.second}]" }
val json = """{"serverId":"$serverId","uptime":$uptimeSeconds,"connections":$playerCount,"maxConnections":256,"bytesInPerSec":$deltaBytesIn,"bytesOutPerSec":$deltaBytesOut,"packetsInPerSec":$deltaPacketsIn,"packetsOutPerSec":$deltaPacketsOut,"players":[$playersJson],"throughputHistory":[$throughputJson],"packetRateHistory":[$packetRateJson]}"""
webSocket.broadcast(json)
} catch (e: Exception) {
plugin.logger.warning("Snapshot error: ${e.message}")
}
}
}
Here is the complete PacketMonitorPlugin.kt (unchanged from the prior step, shown for reference):
package com.example.packetmonitor
import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketListenerPriority
import org.bukkit.plugin.java.JavaPlugin
import java.net.InetSocketAddress
class PacketMonitorPlugin : JavaPlugin() {
private lateinit var webSocketServer: DashboardWebSocket
private lateinit var snapshotEngine: SnapshotEngine
override fun onLoad() {
val eventManager = PacketEvents.getAPI().eventManager
eventManager.registerListener(PacketMonitorListener(), PacketListenerPriority.MONITOR)
}
override fun onEnable() {
val port = config.getInt("dashboard-port", 8099)
val address = InetSocketAddress("0.0.0.0", port)
webSocketServer = DashboardWebSocket(address, this)
webSocketServer.isReuseAddr = true
webSocketServer.start()
snapshotEngine = SnapshotEngine(webSocketServer, this)
snapshotEngine.start()
server.scheduler.runTaskTimer(this, Runnable {
MetricsCollector.updatePlayerHealth(server)
}, 20L, 20L)
logger.info("PacketMonitor dashboard running on http://localhost:$port")
}
override fun onDisable() {
snapshotEngine.stop()
webSocketServer.stop(1000)
}
}
Your WebSocket server is streaming live metrics to any connected client. Next up, you will build the dashboard HTML that turns these JSON snapshots into a slick terminal-themed monitoring interface.
Serve the Dashboard HTML
Your WebSocket server is broadcasting JSON snapshots every 200ms, but there's nothing receiving them yet. The data flows into the void.
In this step, you'll create a single-file HTML dashboard that the plugin serves directly from its JAR. The browser loads the page from the same port, upgrades to a WebSocket connection, and paints the metrics live.
In this step, get ready to:
- Review how DashboardWebSocket serves HTML on the same port as WebSocket.
- Create a dark-themed dashboard.html with CSS custom properties and a terminal-inspired layout.
- Wire up JavaScript to connect via WebSocket and update DOM elements in real time.
Review the HTTP serving mechanism
Your DashboardWebSocket.kt already contains the logic to serve HTML on the same port as WebSocket connections. Open that file and find the onOpen method.
- In IntelliJ IDEA, open DashboardWebSocket.kt from the com.example.packetmonitor package in the Project sidebar.
- Locate the onOpen override and the dashboardHtml lazy property above it.
How does HTTP serving work here?
When a browser navigates to http://localhost:8099, it sends a request that Java-WebSocket intercepts in onOpen. The code checks the request path via handshake.resourceDescriptor.
If the path is / or /index.html, it sends back a raw HTTP response containing the dashboard HTML, then closes the connection. Any other path proceeds as a normal WebSocket upgrade.
The dashboardHtml property lazily reads dashboard.html from the plugin's JAR resources. Right now that file doesn't exist, so the fallback message would display instead.
Create the dashboard HTML structure
The dashboard is a single self-contained HTML file with inline CSS and JavaScript. No build tools, no npm, no frameworks. Everything lives in one file bundled inside the plugin JAR.
- In IntelliJ IDEA, right-click the src/main/resources folder in the Project sidebar.
- Select New, then File.
- Name the file dashboard.html and press Enter.
- Paste the following HTML and CSS into the new file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PacketMonitor - Live Operations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0e14;
--surface: #111820;
--border: #1a2332;
--green: #00ff88;
--green-dim: #00aa55;
--text: #c0c8d4;
--text-dim: #5a6a7a;
--font: 'Courier New', 'Consolas', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 13px;
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px;
}
.status-bar {
margin-bottom: 16px;
font-size: 11px;
text-transform: uppercase;
}
.status-bar .row {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.status-bar .live { color: var(--green); }
.status-bar .bar {
height: 4px;
background: var(--green);
margin-top: 8px;
border-radius: 2px;
}
.nav { list-style: none; margin-bottom: 24px; }
.nav li {
padding: 8px 12px;
cursor: pointer;
text-transform: uppercase;
font-size: 12px;
border-radius: 4px;
margin-bottom: 2px;
}
.nav li.active {
background: var(--green);
color: var(--bg);
font-weight: bold;
}
.nav li:hover:not(.active) { background: var(--border); }
.player-list {
flex: 1;
overflow-y: auto;
}
.player-list h3 {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.player-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
}
.player-item:hover { background: var(--border); }
.player-avatar {
width: 24px;
height: 24px;
background: var(--green-dim);
border-radius: 4px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: var(--bg);
}
.player-name { flex: 1; font-size: 12px; }
.player-ping { font-size: 10px; color: var(--text-dim); }
.bottom-stats {
border-top: 1px solid var(--border);
padding-top: 12px;
font-size: 11px;
}
.bottom-stats .row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.bottom-stats .label { color: var(--text-dim); }
.bottom-stats .value { color: var(--green); }
.main {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header h1 {
font-size: 28px;
font-weight: normal;
text-transform: none;
}
.header h1 span { color: var(--green); }
.header-sub {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.header-right {
text-align: right;
font-size: 11px;
color: var(--text-dim);
}
.connections-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.connections-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}
.connections-value {
font-size: 48px;
color: var(--green);
font-weight: bold;
}
.connections-value .max {
font-size: 20px;
color: var(--text-dim);
font-weight: normal;
}
.connections-delta {
font-size: 11px;
color: var(--green);
margin-top: 4px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
color: var(--green);
font-weight: bold;
}
.stat-value .unit {
font-size: 14px;
color: var(--text-dim);
font-weight: normal;
margin-left: 4px;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 11px;
text-transform: uppercase;
}
.chart-title { color: var(--text); }
.chart-unit { color: var(--text-dim); }
canvas {
width: 100%;
height: 160px;
display: block;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="status-bar">
<div class="row"><span>PROXY</span><span class="live">live</span></div>
<div class="row"><span>BUS</span><span>ok</span></div>
<div class="row"><span>ONLINE</span><span id="sidebar-online">0</span></div>
<div class="bar"></div>
</div>
<ul class="nav">
<li class="active">Dashboard</li>
<li>Players <span id="nav-player-count"></span></li>
<li>Packets</li>
<li>Terminal</li>
</ul>
<div class="player-list">
<h3><span>PLAYERS : <span id="player-count">0</span></span><span>JUMP</span></h3>
<div id="players"></div>
</div>
<div class="bottom-stats">
<div class="row"><span class="label">BYTES</span><span class="value" id="stat-bytes">0 B/s</span></div>
<div class="row"><span class="label">PACKETS</span><span class="value" id="stat-packets">0/s</span></div>
</div>
</div>
<div class="main">
<div class="header">
<div>
<div class="header-sub">OVERVIEW</div>
<h1>Live <span>Operations</span></h1>
</div>
<div class="header-right">
<div>PROTOCOL V775</div>
</div>
</div>
<div class="connections-card">
<div class="connections-label">CONNECTIONS - LIVE</div>
<div class="connections-value"><span id="conn-count">0</span><span class="max"> / <span id="conn-max">256</span></span></div>
<div class="connections-delta" id="conn-delta"></div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">UPTIME</div>
<div class="stat-value" id="uptime">0s</div>
</div>
<div class="stat-card">
<div class="stat-label">PROTOCOL</div>
<div class="stat-value">1.21<span class="unit">MC</span></div>
</div>
<div class="stat-card">
<div class="stat-label">THROUGHPUT</div>
<div class="stat-value"><span id="throughput">0</span><span class="unit">KB /s</span></div>
</div>
<div class="stat-card">
<div class="stat-label">PACKET RATE</div>
<div class="stat-value"><span id="packet-rate">0</span><span class="unit">/s</span></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">NETWORK THROUGHPUT</span>
<span class="chart-unit">BYTES / SECOND</span>
</div>
<canvas id="chart-throughput"></canvas>
</div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">PACKET RATE</span>
<span class="chart-unit">PACKETS / SECOND</span>
</div>
<canvas id="chart-packets"></canvas>
</div>
</div>
</div>
What does this structure do?
The CSS uses custom properties (--bg, --green, --font, etc.) to define the dark terminal theme in one place. Every element references these variables, so you can restyle the entire dashboard by changing a few values in :root.
The layout uses flexbox to split the page into a 240px sidebar and a fluid main area. The sidebar shows server status, navigation, a live player list, and persistent stats. The main area holds the connections card, a 4-column stats grid, and chart containers.
Each DOM element that updates live has an id attribute (like conn-count, uptime, throughput) that the JavaScript will target.
You won't see a change yet. The next chunk adds the JavaScript that brings this page to life.
Add the WebSocket JavaScript
The JavaScript establishes a WebSocket connection back to the plugin on the same host and port. Each incoming JSON snapshot drives a full DOM update.
- In dashboard.html, add the following <script> block immediately after the closing </div> of the .main div (just before </body>):
<script>
const ws = new WebSocket("ws://" + window.location.host);
let lastConnCount = 0;
let connDeltaHistory = [];
ws.onmessage = function(event) {
const d = JSON.parse(event.data);
document.getElementById("conn-count").textContent = d.connections;
document.getElementById("conn-max").textContent = d.maxConnections;
document.getElementById("sidebar-online").textContent = d.connections;
document.getElementById("player-count").textContent = d.connections;
const uptimeSec = d.uptime;
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const s = uptimeSec % 60;
document.getElementById("uptime").textContent = h > 0 ? h + "h " + m + "m" : m > 0 ? m + "m " + s + "s" : s + "s";
const totalBytesPerSec = d.bytesInPerSec + d.bytesOutPerSec;
const totalPacketsPerSec = d.packetsInPerSec + d.packetsOutPerSec;
document.getElementById("throughput").textContent = (totalBytesPerSec / 1024).toFixed(1);
document.getElementById("packet-rate").textContent = totalPacketsPerSec;
document.getElementById("stat-bytes").textContent = (totalBytesPerSec / 1024).toFixed(1) + " KB/s";
document.getElementById("stat-packets").textContent = totalPacketsPerSec + "/s";
const playersEl = document.getElementById("players");
playersEl.innerHTML = "";
for (const p of d.players) {
const initials = p.name.substring(0, 2).toUpperCase();
const div = document.createElement("div");
div.className = "player-item";
div.innerHTML = '<div class="player-avatar">' + initials + '</div><span class="player-name">' + p.name + '</span><span class="player-ping">' + p.version + '</span>';
playersEl.appendChild(div);
}
drawChart("chart-throughput", d.throughputHistory, "bytes");
drawChart("chart-packets", d.packetRateHistory, "packets");
};
function drawChart(canvasId, history, type) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, w, h);
if (!history || history.length < 2) return;
let maxVal = 1;
for (const point of history) {
if (point[0] > maxVal) maxVal = point[0];
if (point[1] > maxVal) maxVal = point[1];
}
maxVal *= 1.2;
ctx.strokeStyle = "#1a2332";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const y = h * (i / 4);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
function drawLine(data, index, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - (data[i][index] / maxVal) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawLine(history, 0, "#ffffff");
drawLine(history, 1, "#00ff88");
}
ws.onclose = function() {
document.querySelector(".status-bar .live").textContent = "disconnected";
document.querySelector(".status-bar .live").style.color = "#ff4444";
};
</script>
</body>
</html>
What does this JavaScript do?
The script opens a WebSocket to ws:// + window.location.host. Since the browser loaded the page from port 8099, the WebSocket connects right back to the same plugin.
Every time a JSON snapshot arrives (5 times per second), the onmessage handler parses it and updates each stat element by ID. It formats uptime into a human-readable string, converts bytes to KB, and rebuilds the player list with avatar initials and client version.
The drawChart function renders a canvas-based line graph using the history arrays from the snapshot. It draws a white line for inbound data and a green line for outbound data, scaling the Y axis dynamically.
- Save dashboard.html.
- Rebuild your plugin by running the Gradle build task (click the green play button in IntelliJ's Gradle panel, or run ./gradlew build from the terminal).
- Copy the new shadow JAR from build/libs/ into your test server's plugins/ folder.
- Restart your PurpurMC test server.
- Open http://localhost:8099 in your browser. You should see a dark-themed dashboard with green monospace text, a sidebar showing PROXY/BUS/ONLINE status, and a main area displaying connections, uptime, throughput, and packet rate stats.
Seeing a blank page or connection refused?
- Check the server console for the startup message PacketMonitor dashboard running on http://localhost:8099. If it's missing, the plugin failed to load.
- Make sure dashboard.html is inside src/main/resources/ (not in a subfolder). The Gradle build bundles resources from that exact location into the JAR root.
- Verify your browser is navigating to port 8099, not the Minecraft server port (25565).
Help me debug why my PacketMonitor dashboard isn't loading in the browser.
✔️ Awesome, I've got everything!
Great. Double check you've saved dashboard.html and that it's sitting in src/main/resources/ alongside plugin.yml.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PacketMonitor - Live Operations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0e14;
--surface: #111820;
--border: #1a2332;
--green: #00ff88;
--green-dim: #00aa55;
--text: #c0c8d4;
--text-dim: #5a6a7a;
--font: 'Courier New', 'Consolas', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 13px;
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px;
}
.status-bar {
margin-bottom: 16px;
font-size: 11px;
text-transform: uppercase;
}
.status-bar .row {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.status-bar .live { color: var(--green); }
.status-bar .bar {
height: 4px;
background: var(--green);
margin-top: 8px;
border-radius: 2px;
}
.nav { list-style: none; margin-bottom: 24px; }
.nav li {
padding: 8px 12px;
cursor: pointer;
text-transform: uppercase;
font-size: 12px;
border-radius: 4px;
margin-bottom: 2px;
}
.nav li.active {
background: var(--green);
color: var(--bg);
font-weight: bold;
}
.nav li:hover:not(.active) { background: var(--border); }
.player-list {
flex: 1;
overflow-y: auto;
}
.player-list h3 {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.player-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
}
.player-item:hover { background: var(--border); }
.player-avatar {
width: 24px;
height: 24px;
background: var(--green-dim);
border-radius: 4px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: var(--bg);
}
.player-name { flex: 1; font-size: 12px; }
.player-ping { font-size: 10px; color: var(--text-dim); }
.bottom-stats {
border-top: 1px solid var(--border);
padding-top: 12px;
font-size: 11px;
}
.bottom-stats .row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.bottom-stats .label { color: var(--text-dim); }
.bottom-stats .value { color: var(--green); }
.main {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header h1 {
font-size: 28px;
font-weight: normal;
text-transform: none;
}
.header h1 span { color: var(--green); }
.header-sub {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.header-right {
text-align: right;
font-size: 11px;
color: var(--text-dim);
}
.connections-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.connections-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}
.connections-value {
font-size: 48px;
color: var(--green);
font-weight: bold;
}
.connections-value .max {
font-size: 20px;
color: var(--text-dim);
font-weight: normal;
}
.connections-delta {
font-size: 11px;
color: var(--green);
margin-top: 4px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
color: var(--green);
font-weight: bold;
}
.stat-value .unit {
font-size: 14px;
color: var(--text-dim);
font-weight: normal;
margin-left: 4px;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 11px;
text-transform: uppercase;
}
.chart-title { color: var(--text); }
.chart-unit { color: var(--text-dim); }
canvas {
width: 100%;
height: 160px;
display: block;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="status-bar">
<div class="row"><span>PROXY</span><span class="live">live</span></div>
<div class="row"><span>BUS</span><span>ok</span></div>
<div class="row"><span>ONLINE</span><span id="sidebar-online">0</span></div>
<div class="bar"></div>
</div>
<ul class="nav">
<li class="active">Dashboard</li>
<li>Players <span id="nav-player-count"></span></li>
<li>Packets</li>
<li>Terminal</li>
</ul>
<div class="player-list">
<h3><span>PLAYERS : <span id="player-count">0</span></span><span>JUMP</span></h3>
<div id="players"></div>
</div>
<div class="bottom-stats">
<div class="row"><span class="label">BYTES</span><span class="value" id="stat-bytes">0 B/s</span></div>
<div class="row"><span class="label">PACKETS</span><span class="value" id="stat-packets">0/s</span></div>
</div>
</div>
<div class="main">
<div class="header">
<div>
<div class="header-sub">OVERVIEW</div>
<h1>Live <span>Operations</span></h1>
</div>
<div class="header-right">
<div>PROTOCOL V775</div>
</div>
</div>
<div class="connections-card">
<div class="connections-label">CONNECTIONS - LIVE</div>
<div class="connections-value"><span id="conn-count">0</span><span class="max"> / <span id="conn-max">256</span></span></div>
<div class="connections-delta" id="conn-delta"></div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">UPTIME</div>
<div class="stat-value" id="uptime">0s</div>
</div>
<div class="stat-card">
<div class="stat-label">PROTOCOL</div>
<div class="stat-value">1.21<span class="unit">MC</span></div>
</div>
<div class="stat-card">
<div class="stat-label">THROUGHPUT</div>
<div class="stat-value"><span id="throughput">0</span><span class="unit">KB /s</span></div>
</div>
<div class="stat-card">
<div class="stat-label">PACKET RATE</div>
<div class="stat-value"><span id="packet-rate">0</span><span class="unit">/s</span></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">NETWORK THROUGHPUT</span>
<span class="chart-unit">BYTES / SECOND</span>
</div>
<canvas id="chart-throughput"></canvas>
</div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">PACKET RATE</span>
<span class="chart-unit">PACKETS / SECOND</span>
</div>
<canvas id="chart-packets"></canvas>
</div>
</div>
</div>
<script>
const ws = new WebSocket("ws://" + window.location.host);
let lastConnCount = 0;
let connDeltaHistory = [];
ws.onmessage = function(event) {
const d = JSON.parse(event.data);
document.getElementById("conn-count").textContent = d.connections;
document.getElementById("conn-max").textContent = d.maxConnections;
document.getElementById("sidebar-online").textContent = d.connections;
document.getElementById("player-count").textContent = d.connections;
const uptimeSec = d.uptime;
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const s = uptimeSec % 60;
document.getElementById("uptime").textContent = h > 0 ? h + "h " + m + "m" : m > 0 ? m + "m " + s + "s" : s + "s";
const totalBytesPerSec = d.bytesInPerSec + d.bytesOutPerSec;
const totalPacketsPerSec = d.packetsInPerSec + d.packetsOutPerSec;
document.getElementById("throughput").textContent = (totalBytesPerSec / 1024).toFixed(1);
document.getElementById("packet-rate").textContent = totalPacketsPerSec;
document.getElementById("stat-bytes").textContent = (totalBytesPerSec / 1024).toFixed(1) + " KB/s";
document.getElementById("stat-packets").textContent = totalPacketsPerSec + "/s";
const playersEl = document.getElementById("players");
playersEl.innerHTML = "";
for (const p of d.players) {
const initials = p.name.substring(0, 2).toUpperCase();
const div = document.createElement("div");
div.className = "player-item";
div.innerHTML = '<div class="player-avatar">' + initials + '</div><span class="player-name">' + p.name + '</span><span class="player-ping">' + p.version + '</span>';
playersEl.appendChild(div);
}
drawChart("chart-throughput", d.throughputHistory, "bytes");
drawChart("chart-packets", d.packetRateHistory, "packets");
};
function drawChart(canvasId, history, type) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, w, h);
if (!history || history.length < 2) return;
let maxVal = 1;
for (const point of history) {
if (point[0] > maxVal) maxVal = point[0];
if (point[1] > maxVal) maxVal = point[1];
}
maxVal *= 1.2;
ctx.strokeStyle = "#1a2332";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const y = h * (i / 4);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
function drawLine(data, index, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - (data[i][index] / maxVal) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawLine(history, 0, "#ffffff");
drawLine(history, 1, "#00ff88");
}
ws.onclose = function() {
document.querySelector(".status-bar .live").textContent = "disconnected";
document.querySelector(".status-bar .live").style.color = "#ff4444";
};
</script>
</body>
</html>
Your dashboard is live and receiving data. Next up, you'll add real-time canvas charts that visualize throughput and packet rate as dynamic line graphs.
Add Real-Time Charts
Your dashboard loads and displays live stats, but the chart canvases are still blank.
Time-series charts are the core of any monitoring dashboard. They let you spot patterns (chunk loading spikes, login storms) that a single number never reveals. In this step, you will build a lightweight canvas renderer that plots throughput and packet rate history in real time.
In this step, get ready to:
- Implement a canvas-based chart renderer with DPR-aware scaling.
- Wire up throughput and packet rate charts to the WebSocket data stream.
- Add stat card updates and a connection status indicator.
Implement the canvas chart renderer
The dashboard already has two <canvas> elements waiting to be drawn on. You need a function that takes history data from the snapshot and renders a line graph with the green-on-dark terminal aesthetic.
- In dashboard.html, find the closing }; of the ws.onmessage function at the bottom of the <script> block.
- Add the following drawChart function below it:
function drawChart(canvasId, history, type) {
// Get the canvas and its 2D drawing context
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext("2d");
// Scale canvas for high-DPI (Retina) displays
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
// Dark background fill
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, w, h);
// Skip drawing if not enough data points
if (!history || history.length < 2) return;
// Find the peak value across both series for Y-axis scaling
let maxVal = 1;
for (const point of history) {
if (point[0] > maxVal) maxVal = point[0];
if (point[1] > maxVal) maxVal = point[1];
}
maxVal *= 1.2;
// Draw horizontal grid lines
ctx.strokeStyle = "#1a2332";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const y = h * (i / 4);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Helper to draw a single data series as a line
function drawLine(data, index, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - (data[i][index] / maxVal) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// White line for ingress/inbound, green line for egress/outbound
drawLine(history, 0, "#ffffff");
drawLine(history, 1, "#00ff88");
}
What does this code do?
The drawChart function renders a time-series line graph onto a <canvas> element. It handles three responsibilities:
- DPR scaling ensures crisp lines on Retina/HiDPI screens by sizing the canvas buffer to the physical pixel count, then scaling the drawing context back down.
- Dynamic Y-axis scaling finds the peak value across both data series and adds 20% headroom, so the chart always fills its vertical space without clipping.
- Two drawLine calls render ingress (white) and egress (green) as separate paths, using the array index to select which value from each [in, out] data point to plot.
- Save dashboard.html.
Canvas stays blank?
- Make sure the function is placed AFTER the ws.onmessage closing brace, not inside it. If it is nested inside, the function won't be globally accessible.
- Check that the canvas IDs in your HTML (chart-throughput and chart-packets) match what you pass to drawChart.
- help me debug why my canvas chart isn't rendering
Wire up the throughput and packet rate charts
The drawChart function exists now, but nothing calls it yet. You need to compute the throughput and packet rate totals from each snapshot, update the stat cards, and trigger both chart redraws.
- Inside the ws.onmessage function, find the line that builds the player list (playersEl.appendChild(div); inside the for loop).
- After the closing brace of that for loop, add the following stat calculations and chart calls:
// Calculate combined throughput and packet rate
const totalBytesPerSec = d.bytesInPerSec + d.bytesOutPerSec;
const totalPacketsPerSec = d.packetsInPerSec + d.packetsOutPerSec;
// Update stat cards with current values
document.getElementById("throughput").textContent = (totalBytesPerSec / 1024).toFixed(1);
document.getElementById("packet-rate").textContent = totalPacketsPerSec;
// Update bottom-left persistent stats bar
document.getElementById("stat-bytes").textContent = (totalBytesPerSec / 1024).toFixed(1) + " KB/s";
document.getElementById("stat-packets").textContent = totalPacketsPerSec + "/s";
// Redraw both time-series charts with snapshot history
drawChart("chart-throughput", d.throughputHistory, "bytes");
drawChart("chart-packets", d.packetRateHistory, "packets");
What does this code do?
- totalBytesPerSec combines ingress and egress into a single throughput number, then divides by 1024 to display in KB/s.
- The stat card elements (throughput, packet-rate) show the big headline numbers in the main dashboard area.
- The bottom stats (stat-bytes, stat-packets) provide a persistent glanceable readout in the sidebar footer.
- Both drawChart calls pass the full 150-entry history arrays from the snapshot, giving the charts 30 seconds of data to render immediately on connect.
- Save dashboard.html.
Stats show but charts still blank?
- The chart needs at least 2 data points to draw. If your server just started, wait a few seconds for the history arrays to accumulate.
- Verify the drawChart calls are inside the ws.onmessage function body (before its closing };), not outside it.
- help me figure out why my chart data isn't rendering
Add the connection status handler
If the WebSocket connection drops (server restart, network issue), the dashboard should clearly signal that data is stale. A simple onclose handler switches the status indicator from green "live" to red "disconnected".
- At the top of your <script> block, find the line const ws = new WebSocket("ws://" + window.location.host);.
- Add these two variable declarations directly below it:
// Track connection count changes over time
let lastConnCount = 0;
let connDeltaHistory = [];
- Now scroll to the very bottom of the <script> block, after the drawChart function's closing brace.
- Add the WebSocket close handler:
// Show disconnected state when WebSocket drops
ws.onclose = function() {
document.querySelector(".status-bar .live").textContent = "disconnected";
document.querySelector(".status-bar .live").style.color = "#ff4444";
};
What does this code do?
- lastConnCount and connDeltaHistory prepare tracking variables for connection delta calculations, making it easy to extend later.
- The ws.onclose handler finds the sidebar status indicator (the element with class .live inside .status-bar) and swaps its text and color to red. This gives immediate visual feedback that data has stopped flowing.
- Save dashboard.html.
- Rebuild the plugin by running the Gradle build task in IntelliJ IDEA, then copy the new JAR to your server's plugins/ folder and restart.
- Open http://localhost:8099 in your browser. Join your test server with a Minecraft client, move around, and watch the charts spike in real time. Throughput should visibly increase during chunk loading.
Charts not updating in real time?
- Make sure you rebuilt the plugin JAR after editing dashboard.html. The HTML is bundled inside the JAR as a resource, so changes require a rebuild.
- Hard-refresh your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear any cached version of the dashboard.
- Check the browser console for JavaScript errors. A typo in a DOM element ID will silently prevent updates.
- help me debug why my real-time charts aren't updating
✔️ Awesome, I've got everything!
Great. Double check you have saved dashboard.html and rebuilt the JAR.
ⓧ I'd like to double check the full code
Here is the complete dashboard.html file for comparison:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PacketMonitor - Live Operations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0e14;
--surface: #111820;
--border: #1a2332;
--green: #00ff88;
--green-dim: #00aa55;
--text: #c0c8d4;
--text-dim: #5a6a7a;
--font: 'Courier New', 'Consolas', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 13px;
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px;
}
.status-bar {
margin-bottom: 16px;
font-size: 11px;
text-transform: uppercase;
}
.status-bar .row {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.status-bar .live { color: var(--green); }
.status-bar .bar {
height: 4px;
background: var(--green);
margin-top: 8px;
border-radius: 2px;
}
.nav { list-style: none; margin-bottom: 24px; }
.nav li {
padding: 8px 12px;
cursor: pointer;
text-transform: uppercase;
font-size: 12px;
border-radius: 4px;
margin-bottom: 2px;
}
.nav li.active {
background: var(--green);
color: var(--bg);
font-weight: bold;
}
.nav li:hover:not(.active) { background: var(--border); }
.player-list {
flex: 1;
overflow-y: auto;
}
.player-list h3 {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.player-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
}
.player-item:hover { background: var(--border); }
.player-avatar {
width: 24px;
height: 24px;
background: var(--green-dim);
border-radius: 4px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: var(--bg);
}
.player-name { flex: 1; font-size: 12px; }
.player-ping { font-size: 10px; color: var(--text-dim); }
.bottom-stats {
border-top: 1px solid var(--border);
padding-top: 12px;
font-size: 11px;
}
.bottom-stats .row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.bottom-stats .label { color: var(--text-dim); }
.bottom-stats .value { color: var(--green); }
.main {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header h1 {
font-size: 28px;
font-weight: normal;
text-transform: none;
}
.header h1 span { color: var(--green); }
.header-sub {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.header-right {
text-align: right;
font-size: 11px;
color: var(--text-dim);
}
.connections-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.connections-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}
.connections-value {
font-size: 48px;
color: var(--green);
font-weight: bold;
}
.connections-value .max {
font-size: 20px;
color: var(--text-dim);
font-weight: normal;
}
.connections-delta {
font-size: 11px;
color: var(--green);
margin-top: 4px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
color: var(--green);
font-weight: bold;
}
.stat-value .unit {
font-size: 14px;
color: var(--text-dim);
font-weight: normal;
margin-left: 4px;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 11px;
text-transform: uppercase;
}
.chart-title { color: var(--text); }
.chart-unit { color: var(--text-dim); }
canvas {
width: 100%;
height: 160px;
display: block;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="status-bar">
<div class="row"><span>PROXY</span><span class="live">live</span></div>
<div class="row"><span>BUS</span><span>ok</span></div>
<div class="row"><span>ONLINE</span><span id="sidebar-online">0</span></div>
<div class="bar"></div>
</div>
<ul class="nav">
<li class="active">Dashboard</li>
<li>Players <span id="nav-player-count"></span></li>
<li>Packets</li>
<li>Terminal</li>
</ul>
<div class="player-list">
<h3><span>PLAYERS : <span id="player-count">0</span></span><span>JUMP</span></h3>
<div id="players"></div>
</div>
<div class="bottom-stats">
<div class="row"><span class="label">BYTES</span><span class="value" id="stat-bytes">0 B/s</span></div>
<div class="row"><span class="label">PACKETS</span><span class="value" id="stat-packets">0/s</span></div>
</div>
</div>
<div class="main">
<div class="header">
<div>
<div class="header-sub">OVERVIEW</div>
<h1>Live <span>Operations</span></h1>
</div>
<div class="header-right">
<div>PROTOCOL V775</div>
</div>
</div>
<div class="connections-card">
<div class="connections-label">CONNECTIONS - LIVE</div>
<div class="connections-value"><span id="conn-count">0</span><span class="max"> / <span id="conn-max">256</span></span></div>
<div class="connections-delta" id="conn-delta"></div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">UPTIME</div>
<div class="stat-value" id="uptime">0s</div>
</div>
<div class="stat-card">
<div class="stat-label">PROTOCOL</div>
<div class="stat-value">1.21<span class="unit">MC</span></div>
</div>
<div class="stat-card">
<div class="stat-label">THROUGHPUT</div>
<div class="stat-value"><span id="throughput">0</span><span class="unit">KB /s</span></div>
</div>
<div class="stat-card">
<div class="stat-label">PACKET RATE</div>
<div class="stat-value"><span id="packet-rate">0</span><span class="unit">/s</span></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">NETWORK THROUGHPUT</span>
<span class="chart-unit">BYTES / SECOND</span>
</div>
<canvas id="chart-throughput"></canvas>
</div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">PACKET RATE</span>
<span class="chart-unit">PACKETS / SECOND</span>
</div>
<canvas id="chart-packets"></canvas>
</div>
</div>
</div>
<script>
const ws = new WebSocket("ws://" + window.location.host);
let lastConnCount = 0;
let connDeltaHistory = [];
ws.onmessage = function(event) {
const d = JSON.parse(event.data);
document.getElementById("conn-count").textContent = d.connections;
document.getElementById("conn-max").textContent = d.maxConnections;
document.getElementById("sidebar-online").textContent = d.connections;
document.getElementById("player-count").textContent = d.connections;
const uptimeSec = d.uptime;
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const s = uptimeSec % 60;
document.getElementById("uptime").textContent = h > 0 ? h + "h " + m + "m" : m > 0 ? m + "m " + s + "s" : s + "s";
const totalBytesPerSec = d.bytesInPerSec + d.bytesOutPerSec;
const totalPacketsPerSec = d.packetsInPerSec + d.packetsOutPerSec;
document.getElementById("throughput").textContent = (totalBytesPerSec / 1024).toFixed(1);
document.getElementById("packet-rate").textContent = totalPacketsPerSec;
document.getElementById("stat-bytes").textContent = (totalBytesPerSec / 1024).toFixed(1) + " KB/s";
document.getElementById("stat-packets").textContent = totalPacketsPerSec + "/s";
const playersEl = document.getElementById("players");
playersEl.innerHTML = "";
for (const p of d.players) {
const initials = p.name.substring(0, 2).toUpperCase();
const div = document.createElement("div");
div.className = "player-item";
div.innerHTML = '<div class="player-avatar">' + initials + '</div><span class="player-name">' + p.name + '</span><span class="player-ping">' + p.version + '</span>';
playersEl.appendChild(div);
}
drawChart("chart-throughput", d.throughputHistory, "bytes");
drawChart("chart-packets", d.packetRateHistory, "packets");
};
function drawChart(canvasId, history, type) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, w, h);
if (!history || history.length < 2) return;
let maxVal = 1;
for (const point of history) {
if (point[0] > maxVal) maxVal = point[0];
if (point[1] > maxVal) maxVal = point[1];
}
maxVal *= 1.2;
ctx.strokeStyle = "#1a2332";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const y = h * (i / 4);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
function drawLine(data, index, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - (data[i][index] / maxVal) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawLine(history, 0, "#ffffff");
drawLine(history, 1, "#00ff88");
}
ws.onclose = function() {
document.querySelector(".status-bar .live").textContent = "disconnected";
document.querySelector(".status-bar .live").style.color = "#ff4444";
};
</script>
</body>
</html>
Secret mission
Per-Player Packet Type Breakdown
Your dashboard shows global metrics, but what about individual players? In this secret mission, you'll add a detail view that shows exactly which packet types each player is sending and receiving. Click a player in the sidebar to see their top-20 packet types ranked by count, with percentage breakdowns and live rates.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- PacketMonitor plugin JAR in your test server's plugins/ folder.
- PacketEvents JAR (packetevents-spigot-2.13.0.jar) in your test server's plugins/ folder.
- PacketMonitor data folder in your test server's plugins/ directory.
- Your Gradle source project folder in IntelliJ IDEA.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the plugin further.
- Your PurpurMC server continues running with the PacketMonitor plugin active.
- The WebSocket dashboard remains accessible at http://localhost:8099 whenever the server is running.
- Port 8099 listens on 0.0.0.0 by default. If your server is publicly accessible, consider restricting the bind address in the plugin config.
Pause - I'll come back to this later
Shut down the server to free up memory. Your plugin files stay in place for next time.
- In your PurpurMC server console, type stop and press Enter to shut down the server gracefully.
- Your plugin JAR, PacketEvents JAR, and source project all remain on disk. Start the server again whenever you want to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the plugin, its data, and your source code.
- Stop your PurpurMC server if it is still running by typing stop in the server console.
- Delete the PacketMonitor plugin JAR from your server's plugins/ folder.
- Delete the PacketEvents JAR from your server's plugins/ folder.
- Delete the plugins/PacketMonitor/ data folder if it exists.
- Delete the plugins/packetevents/ data folder if it exists.
- In IntelliJ IDEA, close the project by clicking File, then Close Project.
- Delete your Gradle project folder from your file system. On macOS/Linux, navigate to the folder in your terminal and run:
rm -rf PacketMonitor
On Windows, right-click the project folder in File Explorer and select Delete.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade network observability plugin for PurpurMC that gives you full real-time visibility into every packet flowing through your server.
You've learned how to:
- Build a Kotlin PurpurMC plugin that intercepts every packet using PacketEvents v2.13.0 with zero main-thread overhead, using AtomicLong counters and ConcurrentHashMap for thread-safe metric collection.
- Create an embedded WebSocket server inside a plugin that pushes JSON snapshots at 200ms intervals, backed by a ring buffer storing 30 seconds of history for instant chart rendering on connect.
- Build a terminal-themed Live Operations web dashboard with canvas-based time-series charts, live stat cards, and a real-time player list. All with a serverId field baked in for future multi-server expansion.
- Secret Mission: Added a per-player packet type breakdown with interactive detail views showing the top-20 inbound and outbound packet types by count.
Ready to quiz yourself?