prettyify code

This commit is contained in:
Lee 2023-12-30 23:03:54 +00:00
parent 6fd5fdb7fe
commit ea15b979d5
28 changed files with 2179 additions and 1688 deletions

@ -3,12 +3,15 @@
</p> </p>
# Minetrack # Minetrack
Minetrack makes it easy to keep an eye on your favorite Minecraft servers. Simple and hackable, Minetrack easily runs on any hardware. Use it for monitoring, analytics, or just for fun. [Check it out](https://minetrack.me). Minetrack makes it easy to keep an eye on your favorite Minecraft servers. Simple and hackable, Minetrack easily runs on any hardware. Use it for monitoring, analytics, or just for fun. [Check it out](https://minetrack.me).
### This project is not actively supported! ### This project is not actively supported
This project is not actively supported. Pull requests will be reviewed and merged (if accepted), but issues _might_ not be addressed outside of fixes provided by community members. Please share any improvements or fixes you've made so everyone can benefit from them. This project is not actively supported. Pull requests will be reviewed and merged (if accepted), but issues _might_ not be addressed outside of fixes provided by community members. Please share any improvements or fixes you've made so everyone can benefit from them.
### Features ### Features
- 🚀 Real time Minecraft server player count tracking with customizable update speed. - 🚀 Real time Minecraft server player count tracking with customizable update speed.
- 📝 Historical player count logging with 24 hour peak and player count record tracking. - 📝 Historical player count logging with 24 hour peak and player count record tracking.
- 📈 Historical graph with customizable time frame. - 📈 Historical graph with customizable time frame.
@ -17,41 +20,37 @@ This project is not actively supported. Pull requests will be reviewed and merge
- 🕹 Supports both Minecraft Java Edition and Minecraft Bedrock Edition. - 🕹 Supports both Minecraft Java Edition and Minecraft Bedrock Edition.
### Community Showcase ### Community Showcase
You can find a list of community hosted instances below. Want to be listed here? Add yourself in a pull request! You can find a list of community hosted instances below. Want to be listed here? Add yourself in a pull request!
* https://minetrack.me - <https://mc.fascinated.cc/>
* https://bedrock.minetrack.me
* https://suomimine.fi
* https://minetrack.geyserconnect.net
* https://minetrack.rmly.dev
* https://minetrack.fi
* https://pvp-factions.fr
* https://stats.liste-serveurs.fr
* https://minetrack.galaxite.dev
* https://livemc.org
## Updates ## Updates
For updates and release notes, please read the [CHANGELOG](docs/CHANGELOG.md). For updates and release notes, please read the [CHANGELOG](docs/CHANGELOG.md).
**Migrating to Minetrack 5?** See the [migration guide](docs/MIGRATING.md). **Migrating to Minetrack 5?** See the [migration guide](docs/MIGRATING.md).
## Installation ## Installation
1. Node 12.4.0+ is required (you can check your version using `node -v`) 1. Node 12.4.0+ is required (you can check your version using `node -v`)
2. Make sure everything is correct in ```config.json```. 2. Make sure everything is correct in `config.json`.
3. Add/remove servers by editing the ```servers.json``` file 3. Add/remove servers by editing the `servers.json` file
4. Run ```npm install``` 4. Run `npm install`
5. Run ```npm run build``` (this bundles `assets/` into `dist/`) 5. Run `npm run build` (this bundles `assets/` into `dist/`)
6. Run ```node main.js``` to boot the system (may need sudo!) 6. Run `node main.js` to boot the system (may need sudo!)
(There's also ```install.sh``` and ```start.sh```, but they may not work for your OS.) (There's also `install.sh` and `start.sh`, but they may not work for your OS.)
Database logging is disabled by default. You can enable it in ```config.json``` by setting ```logToDatabase``` to true. Database logging is disabled by default. You can enable it in `config.json` by setting `logToDatabase` to true.
This requires sqlite3 drivers to be installed. This requires sqlite3 drivers to be installed.
## Docker ## Docker
Minetrack can be built and run with Docker from this repository in several ways: Minetrack can be built and run with Docker from this repository in several ways:
### Build and deploy directly with Docker ### Build and deploy directly with Docker
``` ```
# build image with name minetrack and tag latest # build image with name minetrack and tag latest
docker build . --tag minetrack:latest docker build . --tag minetrack:latest
@ -62,10 +61,12 @@ docker run --rm --publish 80:8080 minetrack:latest
``` ```
The published port can be changed by modifying the parameter argument, e.g.: The published port can be changed by modifying the parameter argument, e.g.:
* Publish to host port 8080: `--publish 8080:8080`
* Publish to localhost (thus prohibiting external access): `--publish 127.0.0.1:8080:8080` - Publish to host port 8080: `--publish 8080:8080`
- Publish to localhost (thus prohibiting external access): `--publish 127.0.0.1:8080:8080`
### Build and deploy with docker-compose ### Build and deploy with docker-compose
``` ```
# build and start service # build and start service
docker-compose up --build docker-compose up --build
@ -75,7 +76,9 @@ docker-compose down
``` ```
## Nginx reverse proxy ## Nginx reverse proxy
The following configuration enables Nginx to act as reverse proxy for a Minetrack instance that is available at port 8080 on localhost: The following configuration enables Nginx to act as reverse proxy for a Minetrack instance that is available at port 8080 on localhost:
``` ```
server { server {
server_name minetrack.example.net; server_name minetrack.example.net;

@ -1,17 +1,17 @@
@font-face { @font-face {
font-family: 'icomoon'; font-family: "icomoon";
src: src: url("../fonts/icomoon.ttf?gn52nv") format("truetype"),
url('../fonts/icomoon.ttf?gn52nv') format('truetype'), url("../fonts/icomoon.woff?gn52nv") format("woff"),
url('../fonts/icomoon.woff?gn52nv') format('woff'), url("../fonts/icomoon.svg?gn52nv#icomoon") format("svg");
url('../fonts/icomoon.svg?gn52nv#icomoon') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
} }
[class^="icon-"], [class*=" icon-"] { [class^="icon-"],
[class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */ /* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important; font-family: "icomoon" !important;
speak: none; speak: none;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;

@ -8,15 +8,15 @@
} }
:root { :root {
--color-dark-gray: #A3A3A3; --color-dark-gray: #a3a3a3;
--color-gold: #FFD700; --color-gold: #ffd700;
--color-dark-purple: #6c5ce7; --color-dark-purple: #6c5ce7;
--color-light-purple: #a29bfe; --color-light-purple: #a29bfe;
--color-dark-blue: #0984e3; --color-dark-blue: #0984e3;
--color-light-blue: #74b9ff; --color-light-blue: #74b9ff;
--theme-color-dark: #3B3738; --theme-color-dark: #3b3738;
--theme-color-light: #EBEBEB; --theme-color-light: #ebebeb;
--border-radius: 1px; --border-radius: 1px;
@ -27,12 +27,12 @@
--background-color: var(--theme-color-light); --background-color: var(--theme-color-light);
--text-decoration-color: var(--theme-color-dark); --text-decoration-color: var(--theme-color-dark);
--text-color: #000; --text-color: #000;
--text-color-inverted: #FFF; --text-color-inverted: #fff;
} }
body { body {
background: #212021; background: #212021;
color: #FFF; color: #fff;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -42,13 +42,13 @@ body {
--color-blue-inverted: var(--color-light-blue); --color-blue-inverted: var(--color-light-blue);
--background-color: var(--theme-color-dark); --background-color: var(--theme-color-dark);
--text-decoration-color: var(--theme-color-light); --text-decoration-color: var(--theme-color-light);
--text-color: #FFF; --text-color: #fff;
--text-color-inverted: #000; --text-color-inverted: #000;
} }
body { body {
background: #1c1b1c; background: #1c1b1c;
color: #FFF; color: #fff;
} }
} }
@ -60,7 +60,8 @@ body {
} }
/* Page layout */ /* Page layout */
html, body { html,
body {
height: 100%; height: 100%;
} }
@ -316,7 +317,9 @@ footer a:hover {
padding-right: 65px; padding-right: 65px;
} }
#big-graph, #big-graph-controls, #big-graph-checkboxes { #big-graph,
#big-graph-controls,
#big-graph-checkboxes {
width: 90%; width: 90%;
} }

@ -1,48 +1,62 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head>
<link rel="stylesheet" href="../css/main.css" />
<head> <link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;700&display=swap"
/>
<link rel="stylesheet" href="../css/main.css"> <link rel="icon" type="image/svg+xml" href="../images/logo.svg" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;700&display=swap"> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../images/logo.svg">
<meta charset="UTF-8">
<script defer type="module" src="../js/main.js"></script> <script defer type="module" src="../js/main.js"></script>
<title>Minetrack</title> <title>Minetrack</title>
</head>
</head> <body>
<body>
<div id="tooltip"></div> <div id="tooltip"></div>
<div id="status-overlay"> <div id="status-overlay">
<img class="logo-image" src="../images/logo.svg"> <img class="logo-image" src="../images/logo.svg" />
<h1 class="logo-text">Minetrack</h1> <h1 class="logo-text">Minetrack</h1>
<div id="status-text">Connecting...</div> <div id="status-text">Connecting...</div>
</div> </div>
<div id="push"> <div id="push">
<div id="perc-bar"></div> <div id="perc-bar"></div>
<header> <header>
<div class="header-possible-row-break column-left"> <div class="header-possible-row-break column-left">
<img class="logo-image" src="../images/logo.svg"> <img class="logo-image" src="../images/logo.svg" />
<h1 class="logo-text">Minetrack</h1> <h1 class="logo-text">Minetrack</h1>
<p class="logo-status">Counting <span class="global-stat" id="stat_totalPlayers">0</span> players on <span class="global-stat" id="stat_networks">0</span> Minecraft servers.</p> <p class="logo-status">
Counting
<span class="global-stat" id="stat_totalPlayers">0</span> players on
<span class="global-stat" id="stat_networks">0</span> Minecraft
servers.
</p>
</div> </div>
<div class="header-possible-row-break column-right"> <div class="header-possible-row-break column-right">
<div id="sort-by" class="header-button header-button-single"><span class="icon-sort-amount-desc"></span> Sort By<br><strong id="sort-by-text">...</strong></div> <div id="sort-by" class="header-button header-button-single">
<span class="icon-sort-amount-desc"></span> Sort By<br /><strong
id="sort-by-text"
>...</strong
>
</div>
<div id="settings-toggle" class="header-button header-button-single" style="margin-left: 20px;"><span class="icon-gears"></span> Graph Controls</div> <div
id="settings-toggle"
class="header-button header-button-single"
style="margin-left: 20px"
>
<span class="icon-gears"></span> Graph Controls
</div>
</div> </div>
</header> </header>
@ -53,21 +67,29 @@
<div id="big-graph-checkboxes"></div> <div id="big-graph-checkboxes"></div>
<span class="graph-controls-setall"> <span class="graph-controls-setall">
<a minetrack-show-type="all" class="button graph-controls-show"><span class="icon-eye"></span> Show All</a> <a minetrack-show-type="all" class="button graph-controls-show"
<a minetrack-show-type="none" class="button graph-controls-show"><span class="icon-eye-slash"></span> Hide All</a> ><span class="icon-eye"></span> Show All</a
<a minetrack-show-type="favorites" class="button graph-controls-show"><span class="icon-star"></span> Only Favorites</a> >
<a minetrack-show-type="none" class="button graph-controls-show"
><span class="icon-eye-slash"></span> Hide All</a
>
<a
minetrack-show-type="favorites"
class="button graph-controls-show"
><span class="icon-star"></span> Only Favorites</a
>
</span> </span>
</div> </div>
</div> </div>
<div id="server-list"></div> <div id="server-list"></div>
</div> </div>
<footer id="footer"> <footer id="footer">
<span class="icon-code"></span> Powered by open source software - <a href="https://github.com/Cryptkeeper/Minetrack">make it your own!</a> <span class="icon-code"></span> Powered by open source software -
<a href="https://git.fascinated.cc/Fascinated/Minetrack"
>make it your own!</a
>
</footer> </footer>
</body>
</body>
</html> </html>

@ -1,100 +1,105 @@
import { ServerRegistry } from './servers' import { FavoritesManager } from "./favorites";
import { SocketManager } from './socket' import { GraphDisplayManager } from "./graph";
import { SortController } from './sort' import { PercentageBar } from "./percbar";
import { GraphDisplayManager } from './graph' import { ServerRegistry } from "./servers";
import { PercentageBar } from './percbar' import { SocketManager } from "./socket";
import { FavoritesManager } from './favorites' import { SortController } from "./sort";
import { Tooltip, Caption, formatNumber } from './util' import { Caption, Tooltip, formatNumber } from "./util";
export class App { export class App {
publicConfig publicConfig;
constructor () { constructor() {
this.tooltip = new Tooltip() this.tooltip = new Tooltip();
this.caption = new Caption() this.caption = new Caption();
this.serverRegistry = new ServerRegistry(this) this.serverRegistry = new ServerRegistry(this);
this.socketManager = new SocketManager(this) this.socketManager = new SocketManager(this);
this.sortController = new SortController(this) this.sortController = new SortController(this);
this.graphDisplayManager = new GraphDisplayManager(this) this.graphDisplayManager = new GraphDisplayManager(this);
this.percentageBar = new PercentageBar(this) this.percentageBar = new PercentageBar(this);
this.favoritesManager = new FavoritesManager(this) this.favoritesManager = new FavoritesManager(this);
this._taskIds = [] this._taskIds = [];
} }
// Called once the DOM is ready and the app can begin setup // Called once the DOM is ready and the app can begin setup
init () { init() {
this.socketManager.createWebSocket() this.socketManager.createWebSocket();
} }
setPageReady (isReady) { setPageReady(isReady) {
document.getElementById('push').style.display = isReady ? 'block' : 'none' document.getElementById("push").style.display = isReady ? "block" : "none";
document.getElementById('footer').style.display = isReady ? 'block' : 'none' document.getElementById("footer").style.display = isReady
document.getElementById('status-overlay').style.display = isReady ? 'none' : 'block' ? "block"
: "none";
document.getElementById("status-overlay").style.display = isReady
? "none"
: "block";
} }
setPublicConfig (publicConfig) { setPublicConfig(publicConfig) {
this.publicConfig = publicConfig this.publicConfig = publicConfig;
this.serverRegistry.assignServers(publicConfig.servers) this.serverRegistry.assignServers(publicConfig.servers);
// Start repeating frontend tasks once it has received enough data to be considered active // Start repeating frontend tasks once it has received enough data to be considered active
// This simplifies management logic at the cost of each task needing to safely handle empty data // This simplifies management logic at the cost of each task needing to safely handle empty data
this.initTasks() this.initTasks();
} }
handleSyncComplete () { handleSyncComplete() {
this.caption.hide() this.caption.hide();
// Load favorites since all servers are registered // Load favorites since all servers are registered
this.favoritesManager.loadLocalStorage() this.favoritesManager.loadLocalStorage();
// Run a single bulk server sort instead of per-add event since there may be multiple // Run a single bulk server sort instead of per-add event since there may be multiple
this.sortController.show() this.sortController.show();
this.percentageBar.redraw() this.percentageBar.redraw();
// The data may not be there to correctly compute values, but run an attempt // The data may not be there to correctly compute values, but run an attempt
// Otherwise they will be updated by #initTasks // Otherwise they will be updated by #initTasks
this.updateGlobalStats() this.updateGlobalStats();
} }
initTasks () { initTasks() {
this._taskIds.push(setInterval(this.sortController.sortServers, 5000)) this._taskIds.push(setInterval(this.sortController.sortServers, 5000));
} }
handleDisconnect () { handleDisconnect() {
this.tooltip.hide() this.tooltip.hide();
// Reset individual tracker elements to flush any held data // Reset individual tracker elements to flush any held data
this.serverRegistry.reset() this.serverRegistry.reset();
this.socketManager.reset() this.socketManager.reset();
this.sortController.reset() this.sortController.reset();
this.graphDisplayManager.reset() this.graphDisplayManager.reset();
this.percentageBar.reset() this.percentageBar.reset();
// Undefine publicConfig, resynced during the connection handshake // Undefine publicConfig, resynced during the connection handshake
this.publicConfig = undefined this.publicConfig = undefined;
// Clear all task ids, if any // Clear all task ids, if any
this._taskIds.forEach(clearInterval) this._taskIds.forEach(clearInterval);
this._taskIds = [] this._taskIds = [];
// Reset hidden values created by #updateGlobalStats // Reset hidden values created by #updateGlobalStats
this._lastTotalPlayerCount = undefined this._lastTotalPlayerCount = undefined;
this._lastServerRegistrationCount = undefined this._lastServerRegistrationCount = undefined;
// Reset modified DOM structures // Reset modified DOM structures
document.getElementById('stat_totalPlayers').innerText = 0 document.getElementById("stat_totalPlayers").innerText = 0;
document.getElementById('stat_networks').innerText = 0 document.getElementById("stat_networks").innerText = 0;
this.setPageReady(false) this.setPageReady(false);
} }
getTotalPlayerCount () { getTotalPlayerCount() {
return this.serverRegistry.getServerRegistrations() return this.serverRegistry
.map(serverRegistration => serverRegistration.playerCount) .getServerRegistrations()
.reduce((sum, current) => sum + current, 0) .map((serverRegistration) => serverRegistration.playerCount)
.reduce((sum, current) => sum + current, 0);
} }
addServer = (serverId, payload, timestampPoints) => { addServer = (serverId, payload, timestampPoints) => {
@ -102,51 +107,61 @@ export class App {
// result = undefined // result = undefined
// error = defined with "Waiting" description // error = defined with "Waiting" description
// info = safely defined with configured data // info = safely defined with configured data
const serverRegistration = this.serverRegistry.createServerRegistration(serverId) const serverRegistration =
this.serverRegistry.createServerRegistration(serverId);
serverRegistration.initServerStatus(payload) serverRegistration.initServerStatus(payload);
// playerCountHistory is only defined when the backend has previous ping data // playerCountHistory is only defined when the backend has previous ping data
// undefined playerCountHistory means this is a placeholder ping generated by the backend // undefined playerCountHistory means this is a placeholder ping generated by the backend
if (typeof payload.playerCountHistory !== 'undefined') { if (typeof payload.playerCountHistory !== "undefined") {
// Push the historical data into the graph // Push the historical data into the graph
// This will trim and format the data so it is ready for the graph to render once init // This will trim and format the data so it is ready for the graph to render once init
serverRegistration.addGraphPoints(payload.playerCountHistory, timestampPoints) serverRegistration.addGraphPoints(
payload.playerCountHistory,
timestampPoints
);
// Set initial playerCount to the payload's value // Set initial playerCount to the payload's value
// This will always exist since it is explicitly generated by the backend // This will always exist since it is explicitly generated by the backend
// This is used for any post-add rendering of things like the percentageBar // This is used for any post-add rendering of things like the percentageBar
serverRegistration.playerCount = payload.playerCount serverRegistration.playerCount = payload.playerCount;
} }
// Create the plot instance internally with the restructured and cleaned data // Create the plot instance internally with the restructured and cleaned data
serverRegistration.buildPlotInstance() serverRegistration.buildPlotInstance();
// Handle the last known state (if any) as an incoming update // Handle the last known state (if any) as an incoming update
// This triggers the main update pipeline and enables centralized update handling // This triggers the main update pipeline and enables centralized update handling
serverRegistration.updateServerStatus(payload, this.publicConfig.minecraftVersions) serverRegistration.updateServerStatus(
payload,
this.publicConfig.minecraftVersions
);
// Allow the ServerRegistration to bind any DOM events with app instance context // Allow the ServerRegistration to bind any DOM events with app instance context
serverRegistration.initEventListeners() serverRegistration.initEventListeners();
} };
updateGlobalStats = () => { updateGlobalStats = () => {
// Only redraw when needed // Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering // These operations are relatively cheap, but the site already does too much rendering
const totalPlayerCount = this.getTotalPlayerCount() const totalPlayerCount = this.getTotalPlayerCount();
if (totalPlayerCount !== this._lastTotalPlayerCount) { if (totalPlayerCount !== this._lastTotalPlayerCount) {
this._lastTotalPlayerCount = totalPlayerCount this._lastTotalPlayerCount = totalPlayerCount;
document.getElementById('stat_totalPlayers').innerText = formatNumber(totalPlayerCount) document.getElementById("stat_totalPlayers").innerText =
formatNumber(totalPlayerCount);
} }
// Only redraw when needed // Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering // These operations are relatively cheap, but the site already does too much rendering
const serverRegistrationCount = this.serverRegistry.getServerRegistrations().length const serverRegistrationCount =
this.serverRegistry.getServerRegistrations().length;
if (serverRegistrationCount !== this._lastServerRegistrationCount) { if (serverRegistrationCount !== this._lastServerRegistrationCount) {
this._lastServerRegistrationCount = serverRegistrationCount this._lastServerRegistrationCount = serverRegistrationCount;
document.getElementById('stat_networks').innerText = serverRegistrationCount document.getElementById("stat_networks").innerText =
} serverRegistrationCount;
} }
};
} }

@ -1,69 +1,83 @@
export const FAVORITE_SERVERS_STORAGE_KEY = 'minetrack_favorite_servers' export const FAVORITE_SERVERS_STORAGE_KEY = "minetrack_favorite_servers";
export class FavoritesManager { export class FavoritesManager {
constructor (app) { constructor(app) {
this._app = app this._app = app;
} }
loadLocalStorage () { loadLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY) let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
if (serverNames) { if (serverNames) {
serverNames = JSON.parse(serverNames) serverNames = JSON.parse(serverNames);
for (let i = 0; i < serverNames.length; i++) { for (let i = 0; i < serverNames.length; i++) {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverNames[i]) const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverNames[i]);
// The serverName may not exist in the backend configuration anymore // The serverName may not exist in the backend configuration anymore
// Ensure serverRegistration is defined before mutating data or considering valid // Ensure serverRegistration is defined before mutating data or considering valid
if (serverRegistration) { if (serverRegistration) {
serverRegistration.isFavorite = true serverRegistration.isFavorite = true;
// Update icon since by default it is unfavorited // Update icon since by default it is unfavorited
document.getElementById(`favorite-toggle_${serverRegistration.serverId}`).setAttribute('class', this.getIconClass(serverRegistration.isFavorite)) document
.getElementById(`favorite-toggle_${serverRegistration.serverId}`)
.setAttribute(
"class",
this.getIconClass(serverRegistration.isFavorite)
);
} }
} }
} }
} }
} }
updateLocalStorage () { updateLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
// Mutate the serverIds array into server names for storage use // Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations() const serverNames = this._app.serverRegistry
.filter(serverRegistration => serverRegistration.isFavorite) .getServerRegistrations()
.map(serverRegistration => serverRegistration.data.name) .filter((serverRegistration) => serverRegistration.isFavorite)
.map((serverRegistration) => serverRegistration.data.name);
if (serverNames.length > 0) { if (serverNames.length > 0) {
// Only save if the array contains data, otherwise clear the item // Only save if the array contains data, otherwise clear the item
localStorage.setItem(FAVORITE_SERVERS_STORAGE_KEY, JSON.stringify(serverNames)) localStorage.setItem(
FAVORITE_SERVERS_STORAGE_KEY,
JSON.stringify(serverNames)
);
} else { } else {
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY) localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY);
} }
} }
} }
handleFavoriteButtonClick = (serverRegistration) => { handleFavoriteButtonClick = (serverRegistration) => {
serverRegistration.isFavorite = !serverRegistration.isFavorite serverRegistration.isFavorite = !serverRegistration.isFavorite;
// Update the displayed favorite icon // Update the displayed favorite icon
document.getElementById(`favorite-toggle_${serverRegistration.serverId}`).setAttribute('class', this.getIconClass(serverRegistration.isFavorite)) document
.getElementById(`favorite-toggle_${serverRegistration.serverId}`)
.setAttribute("class", this.getIconClass(serverRegistration.isFavorite));
// Request the app controller instantly re-sort the server listing // Request the app controller instantly re-sort the server listing
// This handles the favorite sorting logic internally // This handles the favorite sorting logic internally
this._app.sortController.sortServers() this._app.sortController.sortServers();
this._app.graphDisplayManager.handleServerIsFavoriteUpdate(serverRegistration) this._app.graphDisplayManager.handleServerIsFavoriteUpdate(
serverRegistration
);
// Write an updated settings payload // Write an updated settings payload
this.updateLocalStorage() this.updateLocalStorage();
} };
getIconClass (isFavorite) { getIconClass(isFavorite) {
if (isFavorite) { if (isFavorite) {
return 'icon-star server-is-favorite' return "icon-star server-is-favorite";
} else { } else {
return 'icon-star-o server-is-not-favorite' return "icon-star-o server-is-not-favorite";
} }
} }
} }

@ -1,80 +1,87 @@
import uPlot from 'uplot' import uPlot from "uplot";
import { RelativeScale } from './scale' import { RelativeScale } from "./scale";
import { formatNumber, formatTimestampSeconds } from './util' import { uPlotTooltipPlugin } from "./plugins";
import { uPlotTooltipPlugin } from './plugins' import { formatNumber, formatTimestampSeconds } from "./util";
import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' import { FAVORITE_SERVERS_STORAGE_KEY } from "./favorites";
const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers' const HIDDEN_SERVERS_STORAGE_KEY = "minetrack_hidden_servers";
const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites' const SHOW_FAVORITES_STORAGE_KEY = "minetrack_show_favorites";
export class GraphDisplayManager { export class GraphDisplayManager {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._graphData = [] this._graphData = [];
this._graphTimestamps = [] this._graphTimestamps = [];
this._hasLoadedSettings = false this._hasLoadedSettings = false;
this._initEventListenersOnce = false this._initEventListenersOnce = false;
this._showOnlyFavorites = false this._showOnlyFavorites = false;
} }
addGraphPoint (timestamp, playerCounts) { addGraphPoint(timestamp, playerCounts) {
if (!this._hasLoadedSettings) { if (!this._hasLoadedSettings) {
// _hasLoadedSettings is controlled by #setGraphData // _hasLoadedSettings is controlled by #setGraphData
// It will only be true once the context has been loaded and initial payload received // It will only be true once the context has been loaded and initial payload received
// #addGraphPoint should not be called prior to that since it means the data is racing // #addGraphPoint should not be called prior to that since it means the data is racing
// and the application has received updates prior to the initial state // and the application has received updates prior to the initial state
return return;
} }
// Calculate isZoomed before mutating graphData otherwise the indexed values // Calculate isZoomed before mutating graphData otherwise the indexed values
// are out of date and will always fail when compared to plotScaleX.min/max // are out of date and will always fail when compared to plotScaleX.min/max
const plotScaleX = this._plotInstance.scales.x const plotScaleX = this._plotInstance.scales.x;
const isZoomed = plotScaleX.min > this._graphTimestamps[0] || plotScaleX.max < this._graphTimestamps[this._graphTimestamps.length - 1] const isZoomed =
plotScaleX.min > this._graphTimestamps[0] ||
plotScaleX.max < this._graphTimestamps[this._graphTimestamps.length - 1];
this._graphTimestamps.push(timestamp) this._graphTimestamps.push(timestamp);
for (let i = 0; i < playerCounts.length; i++) { for (let i = 0; i < playerCounts.length; i++) {
this._graphData[i].push(playerCounts[i]) this._graphData[i].push(playerCounts[i]);
} }
// Trim all data arrays to only the relevant portion // Trim all data arrays to only the relevant portion
// This keeps it in sync with backend data structures // This keeps it in sync with backend data structures
const graphMaxLength = this._app.publicConfig.graphMaxLength const graphMaxLength = this._app.publicConfig.graphMaxLength;
if (this._graphTimestamps.length > graphMaxLength) { if (this._graphTimestamps.length > graphMaxLength) {
this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength) this._graphTimestamps.splice(
0,
this._graphTimestamps.length - graphMaxLength
);
} }
for (const series of this._graphData) { for (const series of this._graphData) {
if (series.length > graphMaxLength) { if (series.length > graphMaxLength) {
series.splice(0, series.length - graphMaxLength) series.splice(0, series.length - graphMaxLength);
} }
} }
// Avoid redrawing the plot when zoomed // Avoid redrawing the plot when zoomed
this._plotInstance.setData(this.getGraphData(), !isZoomed) this._plotInstance.setData(this.getGraphData(), !isZoomed);
} }
loadLocalStorage () { loadLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY) const showOnlyFavorites = localStorage.getItem(
SHOW_FAVORITES_STORAGE_KEY
);
if (showOnlyFavorites) { if (showOnlyFavorites) {
this._showOnlyFavorites = true this._showOnlyFavorites = true;
} }
// If only favorites mode is active, use the stored favorite servers data instead // If only favorites mode is active, use the stored favorite servers data instead
let serverNames let serverNames;
if (this._showOnlyFavorites) { if (this._showOnlyFavorites) {
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY) serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
} else { } else {
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY) serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY);
} }
if (serverNames) { if (serverNames) {
serverNames = JSON.parse(serverNames) serverNames = JSON.parse(serverNames);
// Iterate over all active serverRegistrations // Iterate over all active serverRegistrations
// This merges saved state with current state to prevent desyncs // This merges saved state with current state to prevent desyncs
@ -83,122 +90,135 @@ export class GraphDisplayManager {
// OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY // OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY
// Checks between FAVORITE/HIDDEN keys are mutually exclusive // Checks between FAVORITE/HIDDEN keys are mutually exclusive
if (this._showOnlyFavorites) { if (this._showOnlyFavorites) {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0 serverRegistration.isVisible =
serverNames.indexOf(serverRegistration.data.name) >= 0;
} else { } else {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0 serverRegistration.isVisible =
serverNames.indexOf(serverRegistration.data.name) < 0;
} }
} }
} }
} }
} }
updateLocalStorage () { updateLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
// Mutate the serverIds array into server names for storage use // Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations() const serverNames = this._app.serverRegistry
.filter(serverRegistration => !serverRegistration.isVisible) .getServerRegistrations()
.map(serverRegistration => serverRegistration.data.name) .filter((serverRegistration) => !serverRegistration.isVisible)
.map((serverRegistration) => serverRegistration.data.name);
// Only store if the array contains data, otherwise clear the item // Only store if the array contains data, otherwise clear the item
// If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead // If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead
if (serverNames.length > 0 && !this._showOnlyFavorites) { if (serverNames.length > 0 && !this._showOnlyFavorites) {
localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames)) localStorage.setItem(
HIDDEN_SERVERS_STORAGE_KEY,
JSON.stringify(serverNames)
);
} else { } else {
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY) localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY);
} }
// Only store SHOW_FAVORITES_STORAGE_KEY if true // Only store SHOW_FAVORITES_STORAGE_KEY if true
if (this._showOnlyFavorites) { if (this._showOnlyFavorites) {
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true) localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true);
} else { } else {
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY) localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY);
} }
} }
} }
getVisibleGraphData () { getVisibleGraphData() {
return this._app.serverRegistry.getServerRegistrations() return this._app.serverRegistry
.filter(serverRegistration => serverRegistration.isVisible) .getServerRegistrations()
.map(serverRegistration => this._graphData[serverRegistration.serverId]) .filter((serverRegistration) => serverRegistration.isVisible)
.map(
(serverRegistration) => this._graphData[serverRegistration.serverId]
);
} }
getPlotSize () { getPlotSize() {
return { return {
width: Math.max(window.innerWidth, 800) * 0.9, width: Math.max(window.innerWidth, 800) * 0.9,
height: 400 height: 400,
};
}
getGraphData() {
return [this._graphTimestamps, ...this._graphData];
}
getGraphDataPoint(serverId, index) {
const graphData = this._graphData[serverId];
if (
graphData &&
index < graphData.length &&
typeof graphData[index] === "number"
) {
return graphData[index];
} }
} }
getGraphData () { getClosestPlotSeriesIndex(idx) {
return [ let closestSeriesIndex = -1;
this._graphTimestamps, let closestSeriesDist = Number.MAX_VALUE;
...this._graphData
]
}
getGraphDataPoint (serverId, index) { const plotHeight = this._plotInstance.bbox.height / devicePixelRatio;
const graphData = this._graphData[serverId]
if (graphData && index < graphData.length && typeof graphData[index] === 'number') {
return graphData[index]
}
}
getClosestPlotSeriesIndex (idx) {
let closestSeriesIndex = -1
let closestSeriesDist = Number.MAX_VALUE
const plotHeight = this._plotInstance.bbox.height / devicePixelRatio
for (let i = 1; i < this._plotInstance.series.length; i++) { for (let i = 1; i < this._plotInstance.series.length; i++) {
const series = this._plotInstance.series[i] const series = this._plotInstance.series[i];
if (!series.show) { if (!series.show) {
continue continue;
} }
const point = this._plotInstance.data[i][idx] const point = this._plotInstance.data[i][idx];
if (typeof point === 'number') { if (typeof point === "number") {
const scale = this._plotInstance.scales[series.scale] const scale = this._plotInstance.scales[series.scale];
const posY = (1 - ((point - scale.min) / (scale.max - scale.min))) * plotHeight const posY =
(1 - (point - scale.min) / (scale.max - scale.min)) * plotHeight;
const dist = Math.abs(posY - this._plotInstance.cursor.top) const dist = Math.abs(posY - this._plotInstance.cursor.top);
if (dist < closestSeriesDist) { if (dist < closestSeriesDist) {
closestSeriesIndex = i closestSeriesIndex = i;
closestSeriesDist = dist closestSeriesDist = dist;
} }
} }
} }
return closestSeriesIndex return closestSeriesIndex;
} }
buildPlotInstance (timestamps, data) { buildPlotInstance(timestamps, data) {
// Lazy load settings from localStorage, if any and if enabled // Lazy load settings from localStorage, if any and if enabled
if (!this._hasLoadedSettings) { if (!this._hasLoadedSettings) {
this._hasLoadedSettings = true this._hasLoadedSettings = true;
this.loadLocalStorage() this.loadLocalStorage();
} }
for (const playerCounts of data) { for (const playerCounts of data) {
// Each playerCounts value corresponds to a ServerRegistration // Each playerCounts value corresponds to a ServerRegistration
// Require each array is the length of timestamps, if not, pad at the start with null values to fit to length // Require each array is the length of timestamps, if not, pad at the start with null values to fit to length
// This ensures newer ServerRegistrations do not left align due to a lower length // This ensures newer ServerRegistrations do not left align due to a lower length
const lengthDiff = timestamps.length - playerCounts.length const lengthDiff = timestamps.length - playerCounts.length;
if (lengthDiff > 0) { if (lengthDiff > 0) {
const padding = Array(lengthDiff).fill(null) const padding = Array(lengthDiff).fill(null);
playerCounts.unshift(...padding) playerCounts.unshift(...padding);
} }
} }
this._graphTimestamps = timestamps this._graphTimestamps = timestamps;
this._graphData = data this._graphData = data;
const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => { const series = this._app.serverRegistry
.getServerRegistrations()
.map((serverRegistration) => {
return { return {
stroke: serverRegistration.data.color, stroke: serverRegistration.data.color,
width: 2, width: 2,
@ -206,261 +226,293 @@ export class GraphDisplayManager {
show: serverRegistration.isVisible, show: serverRegistration.isVisible,
spanGaps: true, spanGaps: true,
points: { points: {
show: false show: false,
} },
} };
}) });
const tickCount = 10 const tickCount = 10;
const maxFactor = 4 const maxFactor = 4;
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
this._plotInstance = new uPlot({ this._plotInstance = new uPlot(
{
plugins: [ plugins: [
uPlotTooltipPlugin((pos, idx) => { uPlotTooltipPlugin((pos, idx) => {
if (pos) { if (pos) {
const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx) const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx);
const text = this._app.serverRegistry.getServerRegistrations() const text =
.filter(serverRegistration => serverRegistration.isVisible) this._app.serverRegistry
.getServerRegistrations()
.filter((serverRegistration) => serverRegistration.isVisible)
.sort((a, b) => { .sort((a, b) => {
if (a.isFavorite !== b.isFavorite) { if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1 return a.isFavorite ? -1 : 1;
} else { } else {
return a.data.name.localeCompare(b.data.name) return a.data.name.localeCompare(b.data.name);
} }
}) })
.map(serverRegistration => { .map((serverRegistration) => {
const point = this.getGraphDataPoint(serverRegistration.serverId, idx) const point = this.getGraphDataPoint(
serverRegistration.serverId,
idx
);
let serverName = serverRegistration.data.name let serverName = serverRegistration.data.name;
if (closestSeriesIndex === serverRegistration.getGraphDataIndex()) { if (
serverName = `<strong>${serverName}</strong>` closestSeriesIndex ===
serverRegistration.getGraphDataIndex()
) {
serverName = `<strong>${serverName}</strong>`;
} }
if (serverRegistration.isFavorite) { if (serverRegistration.isFavorite) {
serverName = `<span class="${this._app.favoritesManager.getIconClass(true)}"></span> ${serverName}` serverName = `<span class="${this._app.favoritesManager.getIconClass(
true
)}"></span> ${serverName}`;
} }
return `${serverName}: ${formatNumber(point)}` return `${serverName}: ${formatNumber(point)}`;
}).join('<br>') + `<br><br><strong>${formatTimestampSeconds(this._graphTimestamps[idx])}</strong>`
this._app.tooltip.set(pos.left, pos.top, 10, 10, text)
} else {
this._app.tooltip.hide()
}
}) })
.join("<br>") +
`<br><br><strong>${formatTimestampSeconds(
this._graphTimestamps[idx]
)}</strong>`;
this._app.tooltip.set(pos.left, pos.top, 10, 10, text);
} else {
this._app.tooltip.hide();
}
}),
], ],
...this.getPlotSize(), ...this.getPlotSize(),
cursor: { cursor: {
y: false y: false,
}, },
series: [ series: [{}, ...series],
{
},
...series
],
axes: [ axes: [
{ {
font: '14px "Open Sans", sans-serif', font: '14px "Open Sans", sans-serif',
stroke: '#FFF', stroke: "#FFF",
grid: { grid: {
show: false show: false,
}, },
space: 60 space: 60,
}, },
{ {
font: '14px "Open Sans", sans-serif', font: '14px "Open Sans", sans-serif',
stroke: '#FFF', stroke: "#FFF",
size: 65, size: 65,
grid: { grid: {
stroke: '#333', stroke: "#333",
width: 1 width: 1,
}, },
split: () => { split: () => {
const visibleGraphData = this.getVisibleGraphData() const visibleGraphData = this.getVisibleGraphData();
const { scaledMax, scale } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor) const { scaledMax, scale } = RelativeScale.scaleMatrix(
const ticks = RelativeScale.generateTicks(0, scaledMax, scale) visibleGraphData,
return ticks tickCount,
} maxFactor
} );
const ticks = RelativeScale.generateTicks(0, scaledMax, scale);
return ticks;
},
},
], ],
scales: { scales: {
y: { y: {
auto: false, auto: false,
range: () => { range: () => {
const visibleGraphData = this.getVisibleGraphData() const visibleGraphData = this.getVisibleGraphData();
const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor) const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(
return [scaledMin, scaledMax] visibleGraphData,
} tickCount,
} maxFactor
);
return [scaledMin, scaledMax];
},
},
}, },
legend: { legend: {
show: false show: false,
} },
}, this.getGraphData(), document.getElementById('big-graph')) },
this.getGraphData(),
document.getElementById("big-graph")
);
// Show the settings-toggle element // Show the settings-toggle element
document.getElementById('settings-toggle').style.display = 'inline-block' document.getElementById("settings-toggle").style.display = "inline-block";
} }
redraw = () => { redraw = () => {
// Use drawing as a hint to update settings // Use drawing as a hint to update settings
// This may cause unnessecary localStorage updates, but its a rare and harmless outcome // This may cause unnessecary localStorage updates, but its a rare and harmless outcome
this.updateLocalStorage() this.updateLocalStorage();
// Copy application state into the series data used by uPlot // Copy application state into the series data used by uPlot
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) { for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
this._plotInstance.series[serverRegistration.getGraphDataIndex()].show = serverRegistration.isVisible this._plotInstance.series[serverRegistration.getGraphDataIndex()].show =
serverRegistration.isVisible;
} }
this._plotInstance.redraw() this._plotInstance.redraw();
} };
requestResize () { requestResize() {
// Only resize when _plotInstance is defined // Only resize when _plotInstance is defined
// Set a timeout to resize after resize events have not been fired for some duration of time // Set a timeout to resize after resize events have not been fired for some duration of time
// This prevents burning CPU time for multiple, rapid resize events // This prevents burning CPU time for multiple, rapid resize events
if (this._plotInstance) { if (this._plotInstance) {
if (this._resizeRequestTimeout) { if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout) clearTimeout(this._resizeRequestTimeout);
} }
// Schedule new delayed resize call // Schedule new delayed resize call
// This can be cancelled by #requestResize, #resize and #reset // This can be cancelled by #requestResize, #resize and #reset
this._resizeRequestTimeout = setTimeout(this.resize, 200) this._resizeRequestTimeout = setTimeout(this.resize, 200);
} }
} }
resize = () => { resize = () => {
this._plotInstance.setSize(this.getPlotSize()) this._plotInstance.setSize(this.getPlotSize());
// undefine value so #clearTimeout is not called // undefine value so #clearTimeout is not called
// This is safe even if #resize is manually called since it removes the pending work // This is safe even if #resize is manually called since it removes the pending work
if (this._resizeRequestTimeout) { if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout) clearTimeout(this._resizeRequestTimeout);
} }
this._resizeRequestTimeout = undefined this._resizeRequestTimeout = undefined;
} };
initEventListeners () { initEventListeners() {
if (!this._initEventListenersOnce) { if (!this._initEventListenersOnce) {
this._initEventListenersOnce = true this._initEventListenersOnce = true;
// These listeners should only be init once since they attach to persistent elements // These listeners should only be init once since they attach to persistent elements
document.getElementById('settings-toggle').addEventListener('click', this.handleSettingsToggle, false) document
.getElementById("settings-toggle")
.addEventListener("click", this.handleSettingsToggle, false);
document.querySelectorAll('.graph-controls-show').forEach((element) => { document.querySelectorAll(".graph-controls-show").forEach((element) => {
element.addEventListener('click', this.handleShowButtonClick, false) element.addEventListener("click", this.handleShowButtonClick, false);
}) });
} }
// These listeners should be bound each #initEventListeners call since they are for newly created elements // These listeners should be bound each #initEventListeners call since they are for newly created elements
document.querySelectorAll('.graph-control').forEach((element) => { document.querySelectorAll(".graph-control").forEach((element) => {
element.addEventListener('click', this.handleServerButtonClick, false) element.addEventListener("click", this.handleServerButtonClick, false);
}) });
} }
handleServerButtonClick = (event) => { handleServerButtonClick = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id')) const serverId = parseInt(event.target.getAttribute("minetrack-server-id"));
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId) const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverId);
if (serverRegistration.isVisible !== event.target.checked) { if (serverRegistration.isVisible !== event.target.checked) {
serverRegistration.isVisible = event.target.checked serverRegistration.isVisible = event.target.checked;
// Any manual changes automatically disables "Only Favorites" mode // Any manual changes automatically disables "Only Favorites" mode
// Otherwise the auto management might overwrite their manual changes // Otherwise the auto management might overwrite their manual changes
this._showOnlyFavorites = false this._showOnlyFavorites = false;
this.redraw() this.redraw();
}
} }
};
handleShowButtonClick = (event) => { handleShowButtonClick = (event) => {
const showType = event.target.getAttribute('minetrack-show-type') const showType = event.target.getAttribute("minetrack-show-type");
// If set to "Only Favorites", set internal state so that // If set to "Only Favorites", set internal state so that
// visible graphData is automatically updating when a ServerRegistration's #isVisible changes // visible graphData is automatically updating when a ServerRegistration's #isVisible changes
// This is also saved and loaded by #loadLocalStorage & #updateLocalStorage // This is also saved and loaded by #loadLocalStorage & #updateLocalStorage
this._showOnlyFavorites = showType === 'favorites' this._showOnlyFavorites = showType === "favorites";
let redraw = false let redraw = false;
this._app.serverRegistry.getServerRegistrations().forEach(function (serverRegistration) { this._app.serverRegistry
let isVisible .getServerRegistrations()
if (showType === 'all') { .forEach(function (serverRegistration) {
isVisible = true let isVisible;
} else if (showType === 'none') { if (showType === "all") {
isVisible = false isVisible = true;
} else if (showType === 'favorites') { } else if (showType === "none") {
isVisible = serverRegistration.isFavorite isVisible = false;
} else if (showType === "favorites") {
isVisible = serverRegistration.isFavorite;
} }
if (serverRegistration.isVisible !== isVisible) { if (serverRegistration.isVisible !== isVisible) {
serverRegistration.isVisible = isVisible serverRegistration.isVisible = isVisible;
redraw = true redraw = true;
} }
}) });
if (redraw) { if (redraw) {
this.redraw() this.redraw();
this.updateCheckboxes() this.updateCheckboxes();
}
} }
};
handleSettingsToggle = () => { handleSettingsToggle = () => {
const element = document.getElementById('big-graph-controls-drawer') const element = document.getElementById("big-graph-controls-drawer");
if (element.style.display !== 'block') { if (element.style.display !== "block") {
element.style.display = 'block' element.style.display = "block";
} else { } else {
element.style.display = 'none' element.style.display = "none";
}
} }
};
handleServerIsFavoriteUpdate = (serverRegistration) => { handleServerIsFavoriteUpdate = (serverRegistration) => {
// When in "Only Favorites" mode, visibility is dependent on favorite status // When in "Only Favorites" mode, visibility is dependent on favorite status
// Redraw and update elements as needed // Redraw and update elements as needed
if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) { if (
serverRegistration.isVisible = serverRegistration.isFavorite this._showOnlyFavorites &&
serverRegistration.isVisible !== serverRegistration.isFavorite
) {
serverRegistration.isVisible = serverRegistration.isFavorite;
this.redraw() this.redraw();
this.updateCheckboxes() this.updateCheckboxes();
} }
};
updateCheckboxes() {
document.querySelectorAll(".graph-control").forEach((checkbox) => {
const serverId = parseInt(checkbox.getAttribute("minetrack-server-id"));
const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverId);
checkbox.checked = serverRegistration.isVisible;
});
} }
updateCheckboxes () { reset() {
document.querySelectorAll('.graph-control').forEach((checkbox) => {
const serverId = parseInt(checkbox.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
checkbox.checked = serverRegistration.isVisible
})
}
reset () {
// Destroy graphs and unload references // Destroy graphs and unload references
// uPlot#destroy handles listener de-registration, DOM reset, etc // uPlot#destroy handles listener de-registration, DOM reset, etc
if (this._plotInstance) { if (this._plotInstance) {
this._plotInstance.destroy() this._plotInstance.destroy();
this._plotInstance = undefined this._plotInstance = undefined;
} }
this._graphTimestamps = [] this._graphTimestamps = [];
this._graphData = [] this._graphData = [];
this._hasLoadedSettings = false this._hasLoadedSettings = false;
// Fire #clearTimeout if the timeout is currently defined // Fire #clearTimeout if the timeout is currently defined
if (this._resizeRequestTimeout) { if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout) clearTimeout(this._resizeRequestTimeout);
this._resizeRequestTimeout = undefined this._resizeRequestTimeout = undefined;
} }
// Reset modified DOM structures // Reset modified DOM structures
document.getElementById('big-graph-checkboxes').innerHTML = '' document.getElementById("big-graph-checkboxes").innerHTML = "";
document.getElementById('big-graph-controls').style.display = 'none' document.getElementById("big-graph-controls").style.display = "none";
document.getElementById('settings-toggle').style.display = 'none' document.getElementById("settings-toggle").style.display = "none";
} }
} }

@ -1,14 +1,22 @@
import { App } from './app' import { App } from "./app";
const app = new App() const app = new App();
document.addEventListener('DOMContentLoaded', () => { document.addEventListener(
app.init() "DOMContentLoaded",
() => {
app.init();
window.addEventListener('resize', function () { window.addEventListener(
app.percentageBar.redraw() "resize",
function () {
app.percentageBar.redraw();
// Delegate to GraphDisplayManager which can check if the resize is necessary // Delegate to GraphDisplayManager which can check if the resize is necessary
app.graphDisplayManager.requestResize() app.graphDisplayManager.requestResize();
}, false) },
}, false) false
);
},
false
);

@ -1,75 +1,99 @@
import { formatNumber, formatPercent } from './util' import { formatNumber, formatPercent } from "./util";
export class PercentageBar { export class PercentageBar {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._parent = document.getElementById('perc-bar') this._parent = document.getElementById("perc-bar");
} }
redraw = () => { redraw = () => {
const serverRegistrations = this._app.serverRegistry.getServerRegistrations().sort(function (a, b) { const serverRegistrations = this._app.serverRegistry
return a.playerCount - b.playerCount .getServerRegistrations()
}) .sort(function (a, b) {
return a.playerCount - b.playerCount;
});
const totalPlayers = this._app.getTotalPlayerCount() const totalPlayers = this._app.getTotalPlayerCount();
let leftPadding = 0 let leftPadding = 0;
for (const serverRegistration of serverRegistrations) { for (const serverRegistration of serverRegistrations) {
const width = Math.round((serverRegistration.playerCount / totalPlayers) * this._parent.offsetWidth) const width = Math.round(
(serverRegistration.playerCount / totalPlayers) *
this._parent.offsetWidth
);
// Update position/width // Update position/width
// leftPadding is a sum of previous iterations width value // leftPadding is a sum of previous iterations width value
const div = document.getElementById(`perc-bar-part_${serverRegistration.serverId}`) || this.createPart(serverRegistration) const div =
document.getElementById(
`perc-bar-part_${serverRegistration.serverId}`
) || this.createPart(serverRegistration);
const widthPixels = `${width}px` const widthPixels = `${width}px`;
const leftPaddingPixels = `${leftPadding}px` const leftPaddingPixels = `${leftPadding}px`;
// Only redraw if needed // Only redraw if needed
if (div.style.width !== widthPixels || div.style.left !== leftPaddingPixels) { if (
div.style.width = widthPixels div.style.width !== widthPixels ||
div.style.left = leftPaddingPixels div.style.left !== leftPaddingPixels
) {
div.style.width = widthPixels;
div.style.left = leftPaddingPixels;
} }
leftPadding += width leftPadding += width;
}
} }
};
createPart (serverRegistration) { createPart(serverRegistration) {
const div = document.createElement('div') const div = document.createElement("div");
div.id = `perc-bar-part_${serverRegistration.serverId}` div.id = `perc-bar-part_${serverRegistration.serverId}`;
div.style.background = serverRegistration.data.color div.style.background = serverRegistration.data.color;
div.setAttribute('class', 'perc-bar-part') div.setAttribute("class", "perc-bar-part");
div.setAttribute('minetrack-server-id', serverRegistration.serverId) div.setAttribute("minetrack-server-id", serverRegistration.serverId);
this._parent.appendChild(div) this._parent.appendChild(div);
// Define events once during creation // Define events once during creation
div.addEventListener('mouseover', this.handleMouseOver, false) div.addEventListener("mouseover", this.handleMouseOver, false);
div.addEventListener('mouseout', this.handleMouseOut, false) div.addEventListener("mouseout", this.handleMouseOut, false);
return div return div;
} }
handleMouseOver = (event) => { handleMouseOver = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id')) const serverId = parseInt(event.target.getAttribute("minetrack-server-id"));
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId) const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverId);
this._app.tooltip.set(event.target.offsetLeft, event.target.offsetTop, 10, this._parent.offsetTop + this._parent.offsetHeight + 10, this._app.tooltip.set(
`${typeof serverRegistration.rankIndex !== 'undefined' ? `#${serverRegistration.rankIndex + 1} ` : ''} event.target.offsetLeft,
event.target.offsetTop,
10,
this._parent.offsetTop + this._parent.offsetHeight + 10,
`${
typeof serverRegistration.rankIndex !== "undefined"
? `#${serverRegistration.rankIndex + 1} `
: ""
}
${serverRegistration.data.name}<br> ${serverRegistration.data.name}<br>
${formatNumber(serverRegistration.playerCount)} Players<br> ${formatNumber(serverRegistration.playerCount)} Players<br>
<strong>${formatPercent(serverRegistration.playerCount, this._app.getTotalPlayerCount())}</strong>`) <strong>${formatPercent(
} serverRegistration.playerCount,
this._app.getTotalPlayerCount()
)}</strong>`
);
};
handleMouseOut = () => { handleMouseOut = () => {
this._app.tooltip.hide() this._app.tooltip.hide();
} };
reset () { reset() {
// Reset modified DOM elements // Reset modified DOM elements
this._parent.innerHTML = '' this._parent.innerHTML = "";
} }
} }

@ -1,28 +1,31 @@
export function uPlotTooltipPlugin (onHover) { export function uPlotTooltipPlugin(onHover) {
let element let element;
return { return {
hooks: { hooks: {
init: u => { init: (u) => {
element = u.root.querySelector('.over') element = u.root.querySelector(".over");
element.onmouseenter = () => onHover() element.onmouseenter = () => onHover();
element.onmouseleave = () => onHover() element.onmouseleave = () => onHover();
}, },
setCursor: u => { setCursor: (u) => {
const { left, top, idx } = u.cursor const { left, top, idx } = u.cursor;
if (idx === null) { if (idx === null) {
onHover() onHover();
} else { } else {
const bounds = element.getBoundingClientRect() const bounds = element.getBoundingClientRect();
onHover({ onHover(
{
left: bounds.left + left + window.pageXOffset, left: bounds.left + left + window.pageXOffset,
top: bounds.top + top + window.pageYOffset top: bounds.top + top + window.pageYOffset,
}, idx) },
} idx
} );
}
} }
},
},
};
} }

@ -1,88 +1,91 @@
export class RelativeScale { export class RelativeScale {
static scale (data, tickCount, maxFactor) { static scale(data, tickCount, maxFactor) {
const { min, max } = RelativeScale.calculateBounds(data) const { min, max } = RelativeScale.calculateBounds(data);
let factor = 1 let factor = 1;
while (true) { while (true) {
const scale = Math.pow(10, factor) const scale = Math.pow(10, factor);
const scaledMin = min - (min % scale) const scaledMin = min - (min % scale);
let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale)) let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale));
// Prevent min/max from being equal (and generating 0 ticks) // Prevent min/max from being equal (and generating 0 ticks)
// This happens when all data points are products of scale value // This happens when all data points are products of scale value
if (scaledMin === scaledMax) { if (scaledMin === scaledMax) {
scaledMax += scale scaledMax += scale;
} }
const ticks = (scaledMax - scaledMin) / scale const ticks = (scaledMax - scaledMin) / scale;
if (ticks <= tickCount || (typeof maxFactor === 'number' && factor === maxFactor)) { if (
ticks <= tickCount ||
(typeof maxFactor === "number" && factor === maxFactor)
) {
return { return {
scaledMin, scaledMin,
scaledMax, scaledMax,
scale scale,
} };
} else { } else {
// Too many steps between min/max, increase factor and try again // Too many steps between min/max, increase factor and try again
factor++ factor++;
} }
} }
} }
static scaleMatrix (data, tickCount, maxFactor) { static scaleMatrix(data, tickCount, maxFactor) {
const nonNullData = data.flat().filter((val) => val !== null) const nonNullData = data.flat().filter((val) => val !== null);
// when used with the spread operator large nonNullData/data arrays can reach the max call stack size // when used with the spread operator large nonNullData/data arrays can reach the max call stack size
// use reduce calls to safely determine min/max values for any size of array // use reduce calls to safely determine min/max values for any size of array
// https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516 // https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516
const max = nonNullData.reduce((a, b) => { const max = nonNullData.reduce((a, b) => {
return Math.max(a, b) return Math.max(a, b);
}, Number.NEGATIVE_INFINITY) }, Number.NEGATIVE_INFINITY);
return RelativeScale.scale( return RelativeScale.scale(
[0, RelativeScale.isFiniteOrZero(max)], [0, RelativeScale.isFiniteOrZero(max)],
tickCount, tickCount,
maxFactor maxFactor
) );
} }
static generateTicks (min, max, step) { static generateTicks(min, max, step) {
const ticks = [] const ticks = [];
for (let i = min; i <= max; i += step) { for (let i = min; i <= max; i += step) {
ticks.push(i) ticks.push(i);
} }
return ticks return ticks;
} }
static calculateBounds (data) { static calculateBounds(data) {
if (data.length === 0) { if (data.length === 0) {
return { return {
min: 0, min: 0,
max: 0 max: 0,
} };
} else { } else {
const nonNullData = data.filter((val) => val !== null) const nonNullData = data.filter((val) => val !== null);
// when used with the spread operator large nonNullData/data arrays can reach the max call stack size // when used with the spread operator large nonNullData/data arrays can reach the max call stack size
// use reduce calls to safely determine min/max values for any size of array // use reduce calls to safely determine min/max values for any size of array
// https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516 // https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516
const min = nonNullData.reduce((a, b) => { const min = nonNullData.reduce((a, b) => {
return Math.min(a, b) return Math.min(a, b);
}, Number.POSITIVE_INFINITY) }, Number.POSITIVE_INFINITY);
const max = nonNullData.reduce((a, b) => { const max = nonNullData.reduce((a, b) => {
return Math.max(a, b) return Math.max(a, b);
}, Number.NEGATIVE_INFINITY) }, Number.NEGATIVE_INFINITY);
return { return {
min: RelativeScale.isFiniteOrZero(min), min: RelativeScale.isFiniteOrZero(min),
max: RelativeScale.isFiniteOrZero(max) max: RelativeScale.isFiniteOrZero(max),
} };
} }
} }
static isFiniteOrZero (val) { static isFiniteOrZero(val) {
return Number.isFinite(val) ? val : 0 return Number.isFinite(val) ? val : 0;
} }
} }

@ -1,102 +1,120 @@
import uPlot from 'uplot' import uPlot from "uplot";
import { RelativeScale } from './scale' import { RelativeScale } from "./scale";
import { formatNumber, formatTimestampSeconds, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util' import { uPlotTooltipPlugin } from "./plugins";
import { uPlotTooltipPlugin } from './plugins' import {
formatDate,
formatMinecraftServerAddress,
formatMinecraftVersions,
formatNumber,
formatTimestampSeconds,
} from "./util";
import MISSING_FAVICON from 'url:../images/missing_favicon.svg' import MISSING_FAVICON from "url:../images/missing_favicon.svg";
export class ServerRegistry { export class ServerRegistry {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._serverIdsByName = [] this._serverIdsByName = [];
this._serverDataById = [] this._serverDataById = [];
this._registeredServers = [] this._registeredServers = [];
} }
assignServers (servers) { assignServers(servers) {
for (let i = 0; i < servers.length; i++) { for (let i = 0; i < servers.length; i++) {
const data = servers[i] const data = servers[i];
this._serverIdsByName[data.name] = i this._serverIdsByName[data.name] = i;
this._serverDataById[i] = data this._serverDataById[i] = data;
} }
} }
createServerRegistration (serverId) { createServerRegistration(serverId) {
const serverData = this._serverDataById[serverId] const serverData = this._serverDataById[serverId];
const serverRegistration = new ServerRegistration(this._app, serverId, serverData) const serverRegistration = new ServerRegistration(
this._registeredServers[serverId] = serverRegistration this._app,
return serverRegistration serverId,
serverData
);
this._registeredServers[serverId] = serverRegistration;
return serverRegistration;
} }
getServerRegistration (serverKey) { getServerRegistration(serverKey) {
if (typeof serverKey === 'string') { if (typeof serverKey === "string") {
const serverId = this._serverIdsByName[serverKey] const serverId = this._serverIdsByName[serverKey];
return this._registeredServers[serverId] return this._registeredServers[serverId];
} else if (typeof serverKey === 'number') { } else if (typeof serverKey === "number") {
return this._registeredServers[serverKey] return this._registeredServers[serverKey];
} }
} }
getServerRegistrations = () => Object.values(this._registeredServers) getServerRegistrations = () => Object.values(this._registeredServers);
reset () { reset() {
this._serverIdsByName = [] this._serverIdsByName = [];
this._serverDataById = [] this._serverDataById = [];
this._registeredServers = [] this._registeredServers = [];
// Reset modified DOM structures // Reset modified DOM structures
document.getElementById('server-list').innerHTML = '' document.getElementById("server-list").innerHTML = "";
} }
} }
export class ServerRegistration { export class ServerRegistration {
playerCount = 0 playerCount = 0;
isVisible = true isVisible = true;
isFavorite = false isFavorite = false;
rankIndex rankIndex;
lastRecordData lastRecordData;
lastPeakData lastPeakData;
constructor (app, serverId, data) { constructor(app, serverId, data) {
this._app = app this._app = app;
this.serverId = serverId this.serverId = serverId;
this.data = data this.data = data;
this._graphData = [[], []] this._graphData = [[], []];
this._failedSequentialPings = 0 this._failedSequentialPings = 0;
} }
getGraphDataIndex () { getGraphDataIndex() {
return this.serverId + 1 return this.serverId + 1;
} }
addGraphPoints (points, timestampPoints) { addGraphPoints(points, timestampPoints) {
this._graphData = [ this._graphData = [timestampPoints.slice(), points];
timestampPoints.slice(),
points
]
} }
buildPlotInstance () { buildPlotInstance() {
const tickCount = 4 const tickCount = 4;
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
this._plotInstance = new uPlot({ this._plotInstance = new uPlot(
{
plugins: [ plugins: [
uPlotTooltipPlugin((pos, id) => { uPlotTooltipPlugin((pos, id) => {
if (pos) { if (pos) {
const playerCount = this._graphData[1][id] const playerCount = this._graphData[1][id];
if (typeof playerCount !== 'number') { if (typeof playerCount !== "number") {
this._app.tooltip.hide() this._app.tooltip.hide();
} else { } else {
this._app.tooltip.set(pos.left, pos.top, 10, 10, `${formatNumber(playerCount)} Players<br>${formatTimestampSeconds(this._graphData[0][id])}`) this._app.tooltip.set(
pos.left,
pos.top,
10,
10,
`${formatNumber(
playerCount
)} Players<br>${formatTimestampSeconds(
this._graphData[0][id]
)}`
);
} }
} else { } else {
this._app.tooltip.hide() this._app.tooltip.hide();
} }
}) }),
], ],
height: 100, height: 100,
width: 400, width: 400,
@ -105,215 +123,274 @@ export class ServerRegistration {
drag: { drag: {
setScale: false, setScale: false,
x: false, x: false,
y: false y: false,
}, },
sync: { sync: {
key: 'minetrack-server', key: "minetrack-server",
setSeries: true setSeries: true,
} },
}, },
series: [ series: [
{}, {},
{ {
stroke: '#E9E581', stroke: "#E9E581",
width: 2, width: 2,
value: (_, raw) => `${formatNumber(raw)} Players`, value: (_, raw) => `${formatNumber(raw)} Players`,
spanGaps: true, spanGaps: true,
points: { points: {
show: false show: false,
} },
} },
], ],
axes: [ axes: [
{ {
show: false show: false,
}, },
{ {
ticks: { ticks: {
show: false show: false,
}, },
font: '14px "Open Sans", sans-serif', font: '14px "Open Sans", sans-serif',
stroke: '#A3A3A3', stroke: "#A3A3A3",
size: 55, size: 55,
grid: { grid: {
stroke: '#333', stroke: "#333",
width: 1 width: 1,
}, },
split: () => { split: () => {
const { scaledMin, scaledMax, scale } = RelativeScale.scale(this._graphData[1], tickCount) const { scaledMin, scaledMax, scale } = RelativeScale.scale(
const ticks = RelativeScale.generateTicks(scaledMin, scaledMax, scale) this._graphData[1],
return ticks tickCount
} );
} const ticks = RelativeScale.generateTicks(
scaledMin,
scaledMax,
scale
);
return ticks;
},
},
], ],
scales: { scales: {
y: { y: {
auto: false, auto: false,
range: () => { range: () => {
const { scaledMin, scaledMax } = RelativeScale.scale(this._graphData[1], tickCount) const { scaledMin, scaledMax } = RelativeScale.scale(
return [scaledMin, scaledMax] this._graphData[1],
} tickCount
} );
return [scaledMin, scaledMax];
},
},
}, },
legend: { legend: {
show: false show: false,
} },
}, this._graphData, document.getElementById(`chart_${this.serverId}`)) },
this._graphData,
document.getElementById(`chart_${this.serverId}`)
);
} }
handlePing (payload, timestamp) { handlePing(payload, timestamp) {
if (typeof payload.playerCount === 'number') { if (typeof payload.playerCount === "number") {
this.playerCount = payload.playerCount this.playerCount = payload.playerCount;
// Reset failed ping counter to ensure the next connection error // Reset failed ping counter to ensure the next connection error
// doesn't instantly retrigger a layout change // doesn't instantly retrigger a layout change
this._failedSequentialPings = 0 this._failedSequentialPings = 0;
} else { } else {
// Attempt to retain a copy of the cached playerCount for up to N failed pings // Attempt to retain a copy of the cached playerCount for up to N failed pings
// This prevents minor connection issues from constantly reshuffling the layout // This prevents minor connection issues from constantly reshuffling the layout
if (++this._failedSequentialPings > 5) { if (++this._failedSequentialPings > 5) {
this.playerCount = 0 this.playerCount = 0;
} }
} }
// Use payload.playerCount so nulls WILL be pushed into the graphing data // Use payload.playerCount so nulls WILL be pushed into the graphing data
this._graphData[0].push(timestamp) this._graphData[0].push(timestamp);
this._graphData[1].push(payload.playerCount) this._graphData[1].push(payload.playerCount);
// Trim graphData to within the max length by shifting out the leading elements // Trim graphData to within the max length by shifting out the leading elements
for (const series of this._graphData) { for (const series of this._graphData) {
if (series.length > this._app.publicConfig.serverGraphMaxLength) { if (series.length > this._app.publicConfig.serverGraphMaxLength) {
series.shift() series.shift();
} }
} }
// Redraw the plot instance // Redraw the plot instance
this._plotInstance.setData(this._graphData) this._plotInstance.setData(this._graphData);
} }
updateServerRankIndex (rankIndex) { updateServerRankIndex(rankIndex) {
this.rankIndex = rankIndex this.rankIndex = rankIndex;
document.getElementById(`ranking_${this.serverId}`).innerText = `#${rankIndex + 1}` document.getElementById(`ranking_${this.serverId}`).innerText = `#${
rankIndex + 1
}`;
} }
_renderValue (prefix, handler) { _renderValue(prefix, handler) {
const labelElement = document.getElementById(`${prefix}_${this.serverId}`) const labelElement = document.getElementById(`${prefix}_${this.serverId}`);
labelElement.style.display = 'block' labelElement.style.display = "block";
const valueElement = document.getElementById(`${prefix}-value_${this.serverId}`) const valueElement = document.getElementById(
const targetElement = valueElement || labelElement `${prefix}-value_${this.serverId}`
);
const targetElement = valueElement || labelElement;
if (targetElement) { if (targetElement) {
if (typeof handler === 'function') { if (typeof handler === "function") {
handler(targetElement) handler(targetElement);
} else { } else {
targetElement.innerText = handler targetElement.innerText = handler;
} }
} }
} }
_hideValue (prefix) { _hideValue(prefix) {
const element = document.getElementById(`${prefix}_${this.serverId}`) const element = document.getElementById(`${prefix}_${this.serverId}`);
element.style.display = 'none' element.style.display = "none";
} }
updateServerStatus (ping, minecraftVersions) { updateServerStatus(ping, minecraftVersions) {
if (ping.versions) { if (ping.versions) {
this._renderValue('version', formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '') this._renderValue(
"version",
formatMinecraftVersions(
ping.versions,
minecraftVersions[this.data.type]
) || ""
);
} }
if (ping.recordData) { if (ping.recordData) {
this._renderValue('record', (element) => { this._renderValue("record", (element) => {
if (ping.recordData.timestamp > 0) { if (ping.recordData.timestamp > 0) {
element.innerText = `${formatNumber(ping.recordData.playerCount)} (${formatDate(ping.recordData.timestamp)})` element.innerText = `${formatNumber(
element.title = `At ${formatDate(ping.recordData.timestamp)} ${formatTimestampSeconds(ping.recordData.timestamp)}` ping.recordData.playerCount
)} (${formatDate(ping.recordData.timestamp)})`;
element.title = `At ${formatDate(
ping.recordData.timestamp
)} ${formatTimestampSeconds(ping.recordData.timestamp)}`;
} else { } else {
element.innerText = formatNumber(ping.recordData.playerCount) element.innerText = formatNumber(ping.recordData.playerCount);
} }
}) });
this.lastRecordData = ping.recordData this.lastRecordData = ping.recordData;
} }
if (ping.graphPeakData) { if (ping.graphPeakData) {
this._renderValue('peak', (element) => { this._renderValue("peak", (element) => {
element.innerText = formatNumber(ping.graphPeakData.playerCount) element.innerText = formatNumber(ping.graphPeakData.playerCount);
element.title = `At ${formatTimestampSeconds(ping.graphPeakData.timestamp)}` element.title = `At ${formatTimestampSeconds(
}) ping.graphPeakData.timestamp
)}`;
});
this.lastPeakData = ping.graphPeakData this.lastPeakData = ping.graphPeakData;
} }
if (ping.error) { if (ping.error) {
this._hideValue('player-count') this._hideValue("player-count");
this._renderValue('error', ping.error.message) this._renderValue("error", ping.error.message);
} else if (typeof ping.playerCount !== 'number') { } else if (typeof ping.playerCount !== "number") {
this._hideValue('player-count') this._hideValue("player-count");
// If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object
// In this case playerCount will safely be null, so provide a generic error message instead // In this case playerCount will safely be null, so provide a generic error message instead
this._renderValue('error', 'Failed to ping') this._renderValue("error", "Failed to ping");
} else if (typeof ping.playerCount === 'number') { } else if (typeof ping.playerCount === "number") {
this._hideValue('error') this._hideValue("error");
this._renderValue('player-count', formatNumber(ping.playerCount)) this._renderValue("player-count", formatNumber(ping.playerCount));
} }
// An updated favicon has been sent, update the src // An updated favicon has been sent, update the src
if (ping.favicon) { if (ping.favicon) {
const faviconElement = document.getElementById(`favicon_${this.serverId}`) const faviconElement = document.getElementById(
`favicon_${this.serverId}`
);
// Since favicons may be URLs, only update the attribute when it has changed // Since favicons may be URLs, only update the attribute when it has changed
// Otherwise the browser may send multiple requests to the same URL // Otherwise the browser may send multiple requests to the same URL
if (faviconElement.getAttribute('src') !== ping.favicon) { if (faviconElement.getAttribute("src") !== ping.favicon) {
faviconElement.setAttribute('src', ping.favicon) faviconElement.setAttribute("src", ping.favicon);
} }
} }
} }
initServerStatus (latestPing) { initServerStatus(latestPing) {
const serverElement = document.createElement('div') const serverElement = document.createElement("div");
serverElement.id = `container_${this.serverId}` serverElement.id = `container_${this.serverId}`;
serverElement.innerHTML = `<div class="column column-favicon"> serverElement.innerHTML = `<div class="column column-favicon">
<img class="server-favicon" src="${latestPing.favicon || MISSING_FAVICON}" id="favicon_${this.serverId}" title="${this.data.name}\n${formatMinecraftServerAddress(this.data.ip, this.data.port)}"> <img class="server-favicon" src="${
latestPing.favicon || MISSING_FAVICON
}" id="favicon_${this.serverId}" title="${
this.data.name
}\n${formatMinecraftServerAddress(this.data.ip, this.data.port)}">
<span class="server-rank" id="ranking_${this.serverId}"></span> <span class="server-rank" id="ranking_${this.serverId}"></span>
</div> </div>
<div class="column column-status"> <div class="column column-status">
<h3 class="server-name"><span class="${this._app.favoritesManager.getIconClass(this.isFavorite)}" id="favorite-toggle_${this.serverId}"></span> ${this.data.name}</h3> <h3 class="server-name"><span class="${this._app.favoritesManager.getIconClass(
this.isFavorite
)}" id="favorite-toggle_${this.serverId}"></span> ${this.data.name}</h3>
<span class="server-error" id="error_${this.serverId}"></span> <span class="server-error" id="error_${this.serverId}"></span>
<span class="server-label" id="player-count_${this.serverId}">Players: <span class="server-value" id="player-count-value_${this.serverId}"></span></span> <span class="server-label" id="player-count_${
<span class="server-label" id="peak_${this.serverId}">${this._app.publicConfig.graphDurationLabel} Peak: <span class="server-value" id="peak-value_${this.serverId}">-</span></span> this.serverId
<span class="server-label" id="record_${this.serverId}">Record: <span class="server-value" id="record-value_${this.serverId}">-</span></span> }">Players: <span class="server-value" id="player-count-value_${
this.serverId
}"></span></span>
<span class="server-label" id="peak_${this.serverId}">${
this._app.publicConfig.graphDurationLabel
} Peak: <span class="server-value" id="peak-value_${
this.serverId
}">-</span></span>
<span class="server-label" id="record_${
this.serverId
}">Record: <span class="server-value" id="record-value_${
this.serverId
}">-</span></span>
<span class="server-label" id="version_${this.serverId}"></span> <span class="server-label" id="version_${this.serverId}"></span>
</div> </div>
<div class="column column-graph" id="chart_${this.serverId}"></div>` <div class="column column-graph" id="chart_${this.serverId}"></div>`;
serverElement.setAttribute('class', 'server') serverElement.setAttribute("class", "server");
document.getElementById('server-list').appendChild(serverElement) document.getElementById("server-list").appendChild(serverElement);
} }
updateHighlightedValue (selectedCategory) { updateHighlightedValue(selectedCategory) {
['player-count', 'peak', 'record'].forEach((category) => { ["player-count", "peak", "record"].forEach((category) => {
const labelElement = document.getElementById(`${category}_${this.serverId}`) const labelElement = document.getElementById(
const valueElement = document.getElementById(`${category}-value_${this.serverId}`) `${category}_${this.serverId}`
);
const valueElement = document.getElementById(
`${category}-value_${this.serverId}`
);
if (selectedCategory && category === selectedCategory) { if (selectedCategory && category === selectedCategory) {
labelElement.setAttribute('class', 'server-highlighted-label') labelElement.setAttribute("class", "server-highlighted-label");
valueElement.setAttribute('class', 'server-highlighted-value') valueElement.setAttribute("class", "server-highlighted-value");
} else { } else {
labelElement.setAttribute('class', 'server-label') labelElement.setAttribute("class", "server-label");
valueElement.setAttribute('class', 'server-value') valueElement.setAttribute("class", "server-value");
} }
}) });
} }
initEventListeners () { initEventListeners() {
document.getElementById(`favorite-toggle_${this.serverId}`).addEventListener('click', () => { document
this._app.favoritesManager.handleFavoriteButtonClick(this) .getElementById(`favorite-toggle_${this.serverId}`)
}, false) .addEventListener(
"click",
() => {
this._app.favoritesManager.handleFavoriteButtonClick(this);
},
false
);
} }
} }

@ -1,176 +1,205 @@
export class SocketManager { export class SocketManager {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._hasRequestedHistoryGraph = false this._hasRequestedHistoryGraph = false;
this._reconnectDelayBase = 0 this._reconnectDelayBase = 0;
} }
reset () { reset() {
this._hasRequestedHistoryGraph = false this._hasRequestedHistoryGraph = false;
} }
createWebSocket () { createWebSocket() {
let webSocketProtocol = 'ws:' let webSocketProtocol = "ws:";
if (location.protocol === 'https:') { if (location.protocol === "https:") {
webSocketProtocol = 'wss:' webSocketProtocol = "wss:";
} }
this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`) this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`);
// The backend will automatically push data once connected // The backend will automatically push data once connected
this._webSocket.onopen = () => { this._webSocket.onopen = () => {
this._app.caption.set('Loading...') this._app.caption.set("Loading...");
// Reset reconnection scheduling since the WebSocket has been established // Reset reconnection scheduling since the WebSocket has been established
this._reconnectDelayBase = 0 this._reconnectDelayBase = 0;
} };
this._webSocket.onclose = (event) => { this._webSocket.onclose = (event) => {
this._app.handleDisconnect() this._app.handleDisconnect();
// Modify page state to display loading overlay // Modify page state to display loading overlay
// Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection // Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection
// See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
// Treat other codes as active errors (besides connectivity errors) when displaying the message // Treat other codes as active errors (besides connectivity errors) when displaying the message
if (event.code === 1006) { if (event.code === 1006) {
this._app.caption.set('Lost connection!') this._app.caption.set("Lost connection!");
} else { } else {
this._app.caption.set('Disconnected due to error.') this._app.caption.set("Disconnected due to error.");
} }
// Schedule socket reconnection attempt // Schedule socket reconnection attempt
this.scheduleReconnect() this.scheduleReconnect();
} };
this._webSocket.onmessage = (message) => { this._webSocket.onmessage = (message) => {
const payload = JSON.parse(message.data) const payload = JSON.parse(message.data);
switch (payload.message) { switch (payload.message) {
case 'init': case "init":
this._app.setPublicConfig(payload.config) this._app.setPublicConfig(payload.config);
// Display the main page component // Display the main page component
// Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn // Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn
this._app.setPageReady(true) this._app.setPageReady(true);
// Allow the graphDisplayManager to control whether or not the historical graph is loaded // Allow the graphDisplayManager to control whether or not the historical graph is loaded
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload // Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
if (this._app.publicConfig.isGraphVisible) { if (this._app.publicConfig.isGraphVisible) {
this.sendHistoryGraphRequest() this.sendHistoryGraphRequest();
} }
payload.servers.forEach((serverPayload, serverId) => { payload.servers.forEach((serverPayload, serverId) => {
this._app.addServer(serverId, serverPayload, payload.timestampPoints) this._app.addServer(
}) serverId,
serverPayload,
payload.timestampPoints
);
});
// Init payload contains all data needed to render the page // Init payload contains all data needed to render the page
// Alert the app it is ready // Alert the app it is ready
this._app.handleSyncComplete() this._app.handleSyncComplete();
break break;
case 'updateServers': { case "updateServers": {
for (let serverId = 0; serverId < payload.updates.length; serverId++) { for (
let serverId = 0;
serverId < payload.updates.length;
serverId++
) {
// The backend may send "update" events prior to receiving all "add" events // The backend may send "update" events prior to receiving all "add" events
// A server has only been added once it's ServerRegistration is defined // A server has only been added once it's ServerRegistration is defined
// Checking undefined protects from this race condition // Checking undefined protects from this race condition
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId) const serverRegistration =
const serverUpdate = payload.updates[serverId] this._app.serverRegistry.getServerRegistration(serverId);
const serverUpdate = payload.updates[serverId];
if (serverRegistration) { if (serverRegistration) {
serverRegistration.handlePing(serverUpdate, payload.timestamp) serverRegistration.handlePing(serverUpdate, payload.timestamp);
serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions) serverRegistration.updateServerStatus(
serverUpdate,
this._app.publicConfig.minecraftVersions
);
} }
} }
// Bulk add playerCounts into graph during #updateHistoryGraph // Bulk add playerCounts into graph during #updateHistoryGraph
if (payload.updateHistoryGraph) { if (payload.updateHistoryGraph) {
this._app.graphDisplayManager.addGraphPoint(payload.timestamp, Object.values(payload.updates).map(update => update.playerCount)) this._app.graphDisplayManager.addGraphPoint(
payload.timestamp,
Object.values(payload.updates).map((update) => update.playerCount)
);
// Run redraw tasks after handling bulk updates // Run redraw tasks after handling bulk updates
this._app.graphDisplayManager.redraw() this._app.graphDisplayManager.redraw();
} }
this._app.percentageBar.redraw() this._app.percentageBar.redraw();
this._app.updateGlobalStats() this._app.updateGlobalStats();
break break;
} }
case 'historyGraph': { case "historyGraph": {
this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData) this._app.graphDisplayManager.buildPlotInstance(
payload.timestamps,
payload.graphData
);
// Build checkbox elements for graph controls // Build checkbox elements for graph controls
let lastRowCounter = 0 let lastRowCounter = 0;
let controlsHTML = '' let controlsHTML = "";
this._app.serverRegistry.getServerRegistrations() this._app.serverRegistry
.map(serverRegistration => serverRegistration.data.name) .getServerRegistrations()
.map((serverRegistration) => serverRegistration.data.name)
.sort() .sort()
.forEach(serverName => { .forEach((serverName) => {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName) const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverName);
controlsHTML += `<td><label> controlsHTML += `<td><label>
<input type="checkbox" class="graph-control" minetrack-server-id="${serverRegistration.serverId}" ${serverRegistration.isVisible ? 'checked' : ''}> <input type="checkbox" class="graph-control" minetrack-server-id="${
serverRegistration.serverId
}" ${serverRegistration.isVisible ? "checked" : ""}>
${serverName} ${serverName}
</label></td>` </label></td>`;
// Occasionally break table rows using a magic number // Occasionally break table rows using a magic number
if (++lastRowCounter % 6 === 0) { if (++lastRowCounter % 6 === 0) {
controlsHTML += '</tr><tr>' controlsHTML += "</tr><tr>";
} }
}) });
// Apply generated HTML and show controls // Apply generated HTML and show controls
document.getElementById('big-graph-checkboxes').innerHTML = `<table><tr>${controlsHTML}</tr></table>` document.getElementById(
document.getElementById('big-graph-controls').style.display = 'block' "big-graph-checkboxes"
).innerHTML = `<table><tr>${controlsHTML}</tr></table>`;
document.getElementById("big-graph-controls").style.display = "block";
// Bind click event for updating graph data // Bind click event for updating graph data
this._app.graphDisplayManager.initEventListeners() this._app.graphDisplayManager.initEventListeners();
break break;
}
} }
} }
};
} }
scheduleReconnect () { scheduleReconnect() {
// Release any active WebSocket references // Release any active WebSocket references
this._webSocket = undefined this._webSocket = undefined;
this._reconnectDelayBase++ this._reconnectDelayBase++;
// Exponential backoff for reconnection attempts // Exponential backoff for reconnection attempts
// Clamp ceiling value to 30 seconds // Clamp ceiling value to 30 seconds
this._reconnectDelaySeconds = Math.min((this._reconnectDelayBase * this._reconnectDelayBase), 30) this._reconnectDelaySeconds = Math.min(
this._reconnectDelayBase * this._reconnectDelayBase,
30
);
const reconnectInterval = setInterval(() => { const reconnectInterval = setInterval(() => {
this._reconnectDelaySeconds-- this._reconnectDelaySeconds--;
if (this._reconnectDelaySeconds === 0) { if (this._reconnectDelaySeconds === 0) {
// Explicitly clear interval, this avoids race conditions // Explicitly clear interval, this avoids race conditions
// #clearInterval first to avoid potential errors causing pre-mature returns // #clearInterval first to avoid potential errors causing pre-mature returns
clearInterval(reconnectInterval) clearInterval(reconnectInterval);
// Update displayed text // Update displayed text
this._app.caption.set('Reconnecting...') this._app.caption.set("Reconnecting...");
// Attempt reconnection // Attempt reconnection
// Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started // Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started
this.createWebSocket() this.createWebSocket();
} else if (this._reconnectDelaySeconds > 0) { } else if (this._reconnectDelaySeconds > 0) {
// Update displayed text // Update displayed text
this._app.caption.set(`Reconnecting in ${this._reconnectDelaySeconds}s...`) this._app.caption.set(
`Reconnecting in ${this._reconnectDelaySeconds}s...`
);
} }
}, 1000) }, 1000);
} }
sendHistoryGraphRequest () { sendHistoryGraphRequest() {
if (!this._hasRequestedHistoryGraph) { if (!this._hasRequestedHistoryGraph) {
this._hasRequestedHistoryGraph = true this._hasRequestedHistoryGraph = true;
// Send request as a plain text string to avoid the server needing to parse JSON // Send request as a plain text string to avoid the server needing to parse JSON
// This is mostly to simplify the backend server's need for error handling // This is mostly to simplify the backend server's need for error handling
this._webSocket.send('requestHistoryGraph') this._webSocket.send("requestHistoryGraph");
} }
} }
} }

@ -1,199 +1,215 @@
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{ {
getName: () => 'Players', getName: () => "Players",
sortFunc: (a, b) => b.playerCount - a.playerCount, sortFunc: (a, b) => b.playerCount - a.playerCount,
highlightedValue: 'player-count' highlightedValue: "player-count",
}, },
{ {
getName: (app) => { getName: (app) => {
return `${app.publicConfig.graphDurationLabel} Peak` return `${app.publicConfig.graphDurationLabel} Peak`;
}, },
sortFunc: (a, b) => { sortFunc: (a, b) => {
if (!a.lastPeakData && !b.lastPeakData) { if (!a.lastPeakData && !b.lastPeakData) {
return 0 return 0;
} else if (a.lastPeakData && !b.lastPeakData) { } else if (a.lastPeakData && !b.lastPeakData) {
return -1 return -1;
} else if (b.lastPeakData && !a.lastPeakData) { } else if (b.lastPeakData && !a.lastPeakData) {
return 1 return 1;
} }
return b.lastPeakData.playerCount - a.lastPeakData.playerCount return b.lastPeakData.playerCount - a.lastPeakData.playerCount;
}, },
testFunc: (app) => { testFunc: (app) => {
// Require at least one ServerRegistration to have a lastPeakData value defined // Require at least one ServerRegistration to have a lastPeakData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) { for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
if (serverRegistration.lastPeakData) { if (serverRegistration.lastPeakData) {
return true return true;
} }
} }
return false return false;
}, },
highlightedValue: 'peak' highlightedValue: "peak",
}, },
{ {
getName: () => 'Record', getName: () => "Record",
sortFunc: (a, b) => { sortFunc: (a, b) => {
if (!a.lastRecordData && !b.lastRecordData) { if (!a.lastRecordData && !b.lastRecordData) {
return 0 return 0;
} else if (a.lastRecordData && !b.lastRecordData) { } else if (a.lastRecordData && !b.lastRecordData) {
return -1 return -1;
} else if (b.lastRecordData && !a.lastRecordData) { } else if (b.lastRecordData && !a.lastRecordData) {
return 1 return 1;
} }
return b.lastRecordData.playerCount - a.lastRecordData.playerCount return b.lastRecordData.playerCount - a.lastRecordData.playerCount;
}, },
testFunc: (app) => { testFunc: (app) => {
// Require at least one ServerRegistration to have a lastRecordData value defined // Require at least one ServerRegistration to have a lastRecordData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) { for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
if (serverRegistration.lastRecordData) { if (serverRegistration.lastRecordData) {
return true return true;
} }
} }
return false return false;
}, },
highlightedValue: 'record' highlightedValue: "record",
} },
] ];
const SORT_OPTION_INDEX_DEFAULT = 0 const SORT_OPTION_INDEX_DEFAULT = 0;
const SORT_OPTION_INDEX_STORAGE_KEY = 'minetrack_sort_option_index' const SORT_OPTION_INDEX_STORAGE_KEY = "minetrack_sort_option_index";
export class SortController { export class SortController {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._buttonElement = document.getElementById('sort-by') this._buttonElement = document.getElementById("sort-by");
this._textElement = document.getElementById('sort-by-text') this._textElement = document.getElementById("sort-by-text");
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT;
} }
reset () { reset() {
this._lastSortedServers = undefined this._lastSortedServers = undefined;
// Reset modified DOM structures // Reset modified DOM structures
this._buttonElement.style.display = 'none' this._buttonElement.style.display = "none";
this._textElement.innerText = '...' this._textElement.innerText = "...";
// Remove bound DOM event listeners // Remove bound DOM event listeners
this._buttonElement.removeEventListener('click', this.handleSortByButtonClick) this._buttonElement.removeEventListener(
"click",
this.handleSortByButtonClick
);
} }
loadLocalStorage () { loadLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
const sortOptionIndex = localStorage.getItem(SORT_OPTION_INDEX_STORAGE_KEY) const sortOptionIndex = localStorage.getItem(
SORT_OPTION_INDEX_STORAGE_KEY
);
if (sortOptionIndex) { if (sortOptionIndex) {
this._sortOptionIndex = parseInt(sortOptionIndex) this._sortOptionIndex = parseInt(sortOptionIndex);
} }
} }
} }
updateLocalStorage () { updateLocalStorage() {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== "undefined") {
if (this._sortOptionIndex !== SORT_OPTION_INDEX_DEFAULT) { if (this._sortOptionIndex !== SORT_OPTION_INDEX_DEFAULT) {
localStorage.setItem(SORT_OPTION_INDEX_STORAGE_KEY, this._sortOptionIndex) localStorage.setItem(
SORT_OPTION_INDEX_STORAGE_KEY,
this._sortOptionIndex
);
} else { } else {
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY) localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY);
} }
} }
} }
show () { show() {
// Load the saved option selection, if any // Load the saved option selection, if any
this.loadLocalStorage() this.loadLocalStorage();
this.updateSortOption() this.updateSortOption();
// Bind DOM event listeners // Bind DOM event listeners
// This is removed by #reset to avoid multiple listeners // This is removed by #reset to avoid multiple listeners
this._buttonElement.addEventListener('click', this.handleSortByButtonClick) this._buttonElement.addEventListener("click", this.handleSortByButtonClick);
// Show #sort-by element // Show #sort-by element
this._buttonElement.style.display = 'inline-block' this._buttonElement.style.display = "inline-block";
} }
handleSortByButtonClick = () => { handleSortByButtonClick = () => {
while (true) { while (true) {
// Increment to the next sort option, wrap around if needed // Increment to the next sort option, wrap around if needed
this._sortOptionIndex = (this._sortOptionIndex + 1) % SORT_OPTIONS.length this._sortOptionIndex = (this._sortOptionIndex + 1) % SORT_OPTIONS.length;
// Only break if the sortOption is supported // Only break if the sortOption is supported
// This can technically cause an infinite loop, but never should assuming // This can technically cause an infinite loop, but never should assuming
// at least one sortOption does not implement the test OR always returns true // at least one sortOption does not implement the test OR always returns true
const sortOption = SORT_OPTIONS[this._sortOptionIndex] const sortOption = SORT_OPTIONS[this._sortOptionIndex];
if (!sortOption.testFunc || sortOption.testFunc(this._app)) { if (!sortOption.testFunc || sortOption.testFunc(this._app)) {
break break;
} }
} }
// Redraw the button and sort the servers // Redraw the button and sort the servers
this.updateSortOption() this.updateSortOption();
// Save the updated option selection // Save the updated option selection
this.updateLocalStorage() this.updateLocalStorage();
} };
updateSortOption = () => { updateSortOption = () => {
const sortOption = SORT_OPTIONS[this._sortOptionIndex] const sortOption = SORT_OPTIONS[this._sortOptionIndex];
// Pass app instance so sortOption names can be dynamically generated // Pass app instance so sortOption names can be dynamically generated
this._textElement.innerText = sortOption.getName(this._app) this._textElement.innerText = sortOption.getName(this._app);
// Update all servers highlighted values // Update all servers highlighted values
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) { for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
serverRegistration.updateHighlightedValue(sortOption.highlightedValue) serverRegistration.updateHighlightedValue(sortOption.highlightedValue);
} }
this.sortServers() this.sortServers();
} };
sortServers = () => { sortServers = () => {
const sortOption = SORT_OPTIONS[this._sortOptionIndex] const sortOption = SORT_OPTIONS[this._sortOptionIndex];
const sortedServers = this._app.serverRegistry.getServerRegistrations().sort((a, b) => { const sortedServers = this._app.serverRegistry
.getServerRegistrations()
.sort((a, b) => {
if (a.isFavorite && !b.isFavorite) { if (a.isFavorite && !b.isFavorite) {
return -1 return -1;
} else if (b.isFavorite && !a.isFavorite) { } else if (b.isFavorite && !a.isFavorite) {
return 1 return 1;
} }
return sortOption.sortFunc(a, b) return sortOption.sortFunc(a, b);
}) });
// Test if sortedServers has changed from the previous listing // Test if sortedServers has changed from the previous listing
// This avoids DOM updates and graphs being redrawn // This avoids DOM updates and graphs being redrawn
const sortedServerIds = sortedServers.map(server => server.serverId) const sortedServerIds = sortedServers.map((server) => server.serverId);
if (this._lastSortedServers) { if (this._lastSortedServers) {
let allMatch = true let allMatch = true;
// Test if the arrays have actually changed // Test if the arrays have actually changed
// No need to length check, they are the same source data each time // No need to length check, they are the same source data each time
for (let i = 0; i < sortedServerIds.length; i++) { for (let i = 0; i < sortedServerIds.length; i++) {
if (sortedServerIds[i] !== this._lastSortedServers[i]) { if (sortedServerIds[i] !== this._lastSortedServers[i]) {
allMatch = false allMatch = false;
break break;
} }
} }
if (allMatch) { if (allMatch) {
return return;
} }
} }
this._lastSortedServers = sortedServerIds this._lastSortedServers = sortedServerIds;
// Sort a ServerRegistration list by the sortOption ONLY // Sort a ServerRegistration list by the sortOption ONLY
// This is used to determine the ServerRegistration's rankIndex without #isFavorite skewing values // This is used to determine the ServerRegistration's rankIndex without #isFavorite skewing values
const rankIndexSort = this._app.serverRegistry.getServerRegistrations().sort(sortOption.sortFunc) const rankIndexSort = this._app.serverRegistry
.getServerRegistrations()
.sort(sortOption.sortFunc);
// Update the DOM structure // Update the DOM structure
sortedServers.forEach(function (serverRegistration) { sortedServers.forEach(function (serverRegistration) {
const parentElement = document.getElementById('server-list') const parentElement = document.getElementById("server-list");
const serverElement = document.getElementById(`container_${serverRegistration.serverId}`) const serverElement = document.getElementById(
`container_${serverRegistration.serverId}`
);
parentElement.appendChild(serverElement) parentElement.appendChild(serverElement);
// Set the ServerRegistration's rankIndex to its indexOf the normal sort // Set the ServerRegistration's rankIndex to its indexOf the normal sort
serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration)) serverRegistration.updateServerRankIndex(
}) rankIndexSort.indexOf(serverRegistration)
} );
});
};
} }

@ -1,124 +1,131 @@
export class Tooltip { export class Tooltip {
constructor () { constructor() {
this._div = document.getElementById('tooltip') this._div = document.getElementById("tooltip");
} }
set (x, y, offsetX, offsetY, html) { set(x, y, offsetX, offsetY, html) {
this._div.innerHTML = html this._div.innerHTML = html;
// Assign display: block so that the offsetWidth is valid // Assign display: block so that the offsetWidth is valid
this._div.style.display = 'block' this._div.style.display = "block";
// Prevent the div from overflowing the page width // Prevent the div from overflowing the page width
const tooltipWidth = this._div.offsetWidth const tooltipWidth = this._div.offsetWidth;
// 1.2 is a magic number used to pad the offset to ensure the tooltip // 1.2 is a magic number used to pad the offset to ensure the tooltip
// never gets close or surpasses the page's X width // never gets close or surpasses the page's X width
if (x + offsetX + (tooltipWidth * 1.2) > window.innerWidth) { if (x + offsetX + tooltipWidth * 1.2 > window.innerWidth) {
x -= tooltipWidth x -= tooltipWidth;
offsetX *= -1 offsetX *= -1;
} }
this._div.style.top = `${y + offsetY}px` this._div.style.top = `${y + offsetY}px`;
this._div.style.left = `${x + offsetX}px` this._div.style.left = `${x + offsetX}px`;
} }
hide = () => { hide = () => {
this._div.style.display = 'none' this._div.style.display = "none";
} };
} }
export class Caption { export class Caption {
constructor () { constructor() {
this._div = document.getElementById('status-text') this._div = document.getElementById("status-text");
} }
set (text) { set(text) {
this._div.innerText = text this._div.innerText = text;
this._div.style.display = 'block' this._div.style.display = "block";
} }
hide () { hide() {
this._div.style.display = 'none' this._div.style.display = "none";
} }
} }
// Minecraft Java Edition default server port: 25565 // Minecraft Java Edition default server port: 25565
// Minecraft Bedrock Edition default server port: 19132 // Minecraft Bedrock Edition default server port: 19132
const MINECRAFT_DEFAULT_PORTS = [25565, 19132] const MINECRAFT_DEFAULT_PORTS = [25565, 19132];
export function formatMinecraftServerAddress (ip, port) { export function formatMinecraftServerAddress(ip, port) {
if (port && !MINECRAFT_DEFAULT_PORTS.includes(port)) { if (port && !MINECRAFT_DEFAULT_PORTS.includes(port)) {
return `${ip}:${port}` return `${ip}:${port}`;
} }
return ip return ip;
} }
// Detect gaps in versions by matching their indexes to knownVersions // Detect gaps in versions by matching their indexes to knownVersions
export function formatMinecraftVersions (versions, knownVersions) { export function formatMinecraftVersions(versions, knownVersions) {
if (!versions || !versions.length || !knownVersions || !knownVersions.length) { if (
return !versions ||
!versions.length ||
!knownVersions ||
!knownVersions.length
) {
return;
} }
let currentVersionGroup = [] let currentVersionGroup = [];
const versionGroups = [] const versionGroups = [];
for (let i = 0; i < versions.length; i++) { for (let i = 0; i < versions.length; i++) {
// Look for value mismatch between the previous index // Look for value mismatch between the previous index
// Require i > 0 since lastVersionIndex is undefined for i === 0 // Require i > 0 since lastVersionIndex is undefined for i === 0
if (i > 0 && versions[i] - versions[i - 1] !== 1) { if (i > 0 && versions[i] - versions[i - 1] !== 1) {
versionGroups.push(currentVersionGroup) versionGroups.push(currentVersionGroup);
currentVersionGroup = [] currentVersionGroup = [];
} }
currentVersionGroup.push(versions[i]) currentVersionGroup.push(versions[i]);
} }
// Ensure the last versionGroup is always pushed // Ensure the last versionGroup is always pushed
if (currentVersionGroup.length > 0) { if (currentVersionGroup.length > 0) {
versionGroups.push(currentVersionGroup) versionGroups.push(currentVersionGroup);
} }
if (versionGroups.length === 0) { if (versionGroups.length === 0) {
return return;
} }
// Remap individual versionGroups values into named versions // Remap individual versionGroups values into named versions
return versionGroups.map(versionGroup => { return versionGroups
const startVersion = knownVersions[versionGroup[0]] .map((versionGroup) => {
const startVersion = knownVersions[versionGroup[0]];
if (versionGroup.length === 1) { if (versionGroup.length === 1) {
// A versionGroup may contain a single version, only return its name // A versionGroup may contain a single version, only return its name
// This is a cosmetic catch to avoid version labels like 1.0-1.0 // This is a cosmetic catch to avoid version labels like 1.0-1.0
return startVersion return startVersion;
} else { } else {
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]] const endVersion = knownVersions[versionGroup[versionGroup.length - 1]];
return `${startVersion}-${endVersion}` return `${startVersion}-${endVersion}`;
} }
}).join(', ') })
.join(", ");
} }
export function formatTimestampSeconds (secs) { export function formatTimestampSeconds(secs) {
const date = new Date(0) const date = new Date(0);
date.setUTCSeconds(secs) date.setUTCSeconds(secs);
return date.toLocaleTimeString() return date.toLocaleTimeString();
} }
export function formatDate (secs) { export function formatDate(secs) {
const date = new Date(0) const date = new Date(0);
date.setUTCSeconds(secs) date.setUTCSeconds(secs);
return date.toLocaleDateString() return date.toLocaleDateString();
} }
export function formatPercent (x, over) { export function formatPercent(x, over) {
const val = Math.round((x / over) * 100 * 10) / 10 const val = Math.round((x / over) * 100 * 10) / 10;
return `${val}%` return `${val}%`;
} }
export function formatNumber (x) { export function formatNumber(x) {
if (typeof x !== 'number') { if (typeof x !== "number") {
return '-' return "-";
} else { } else {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
} }
} }

@ -1,87 +1,99 @@
const Database = require('./database') const Database = require("./database");
const PingController = require('./ping') const PingController = require("./ping");
const Server = require('./server') const Server = require("./server");
const { TimeTracker } = require('./time') const { TimeTracker } = require("./time");
const MessageOf = require('./message') const MessageOf = require("./message");
const config = require('../config') const config = require("../config");
const minecraftVersions = require('../minecraft_versions') const minecraftVersions = require("../minecraft_versions");
class App { class App {
serverRegistrations = [] serverRegistrations = [];
constructor () { constructor() {
this.pingController = new PingController(this) this.pingController = new PingController(this);
this.server = new Server(this) this.server = new Server(this);
this.timeTracker = new TimeTracker(this) this.timeTracker = new TimeTracker(this);
} }
loadDatabase (callback) { loadDatabase(callback) {
this.database = new Database(this) this.database = new Database(this);
// Setup database instance // Setup database instance
this.database.ensureIndexes(() => { this.database.ensureIndexes(() => {
this.database.loadGraphPoints(config.graphDuration, () => { this.database.loadGraphPoints(config.graphDuration, () => {
this.database.loadRecords(() => { this.database.loadRecords(() => {
if (config.oldPingsCleanup && config.oldPingsCleanup.enabled) { if (config.oldPingsCleanup && config.oldPingsCleanup.enabled) {
this.database.initOldPingsDelete(callback) this.database.initOldPingsDelete(callback);
} else { } else {
callback() callback();
} }
}) });
}) });
}) });
} }
handleReady () { handleReady() {
this.server.listen(config.site.ip, config.site.port) this.server.listen(config.site.ip, config.site.port);
// Allow individual modules to manage their own task scheduling // Allow individual modules to manage their own task scheduling
this.pingController.schedule() this.pingController.schedule();
} }
handleClientConnection = (client) => { handleClientConnection = (client) => {
if (config.logToDatabase) { if (config.logToDatabase) {
client.on('message', (message) => { client.on("message", (message) => {
if (message === 'requestHistoryGraph') { if (message === "requestHistoryGraph") {
// Send historical graphData built from all serverRegistrations // Send historical graphData built from all serverRegistrations
const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData) const graphData = this.serverRegistrations.map(
(serverRegistration) => serverRegistration.graphData
);
// Send graphData in object wrapper to avoid needing to explicity filter // Send graphData in object wrapper to avoid needing to explicity filter
// any header data being appended by #MessageOf since the graph data is fed // any header data being appended by #MessageOf since the graph data is fed
// directly into the graphing system // directly into the graphing system
client.send(MessageOf('historyGraph', { client.send(
MessageOf("historyGraph", {
timestamps: this.timeTracker.getGraphPoints(), timestamps: this.timeTracker.getGraphPoints(),
graphData graphData,
}))
}
}) })
);
}
});
} }
const initMessage = { const initMessage = {
config: (() => { config: (() => {
// Remap minecraftVersion entries into name values // Remap minecraftVersion entries into name values
const minecraftVersionNames = {} const minecraftVersionNames = {};
Object.keys(minecraftVersions).forEach(function (key) { Object.keys(minecraftVersions).forEach(function (key) {
minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name) minecraftVersionNames[key] = minecraftVersions[key].map(
}) (version) => version.name
);
});
// Send configuration data for rendering the page // Send configuration data for rendering the page
return { return {
graphDurationLabel: config.graphDurationLabel || (Math.floor(config.graphDuration / (60 * 60 * 1000)) + 'h'), graphDurationLabel:
config.graphDurationLabel ||
Math.floor(config.graphDuration / (60 * 60 * 1000)) + "h",
graphMaxLength: TimeTracker.getMaxGraphDataLength(), graphMaxLength: TimeTracker.getMaxGraphDataLength(),
serverGraphMaxLength: TimeTracker.getMaxServerGraphDataLength(), serverGraphMaxLength: TimeTracker.getMaxServerGraphDataLength(),
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPublicData()), servers: this.serverRegistrations.map((serverRegistration) =>
serverRegistration.getPublicData()
),
minecraftVersions: minecraftVersionNames, minecraftVersions: minecraftVersionNames,
isGraphVisible: config.logToDatabase isGraphVisible: config.logToDatabase,
} };
})(), })(),
timestampPoints: this.timeTracker.getServerGraphPoints(), timestampPoints: this.timeTracker.getServerGraphPoints(),
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) servers: this.serverRegistrations.map((serverRegistration) =>
} serverRegistration.getPingHistory()
),
};
client.send(MessageOf('init', initMessage)) client.send(MessageOf("init", initMessage));
} };
} }
module.exports = App module.exports = App;

@ -1,308 +1,364 @@
const sqlite = require('sqlite3') const sqlite = require("sqlite3");
const logger = require('./logger') const logger = require("./logger");
const config = require('../config') const config = require("../config");
const { TimeTracker } = require('./time') const { TimeTracker } = require("./time");
const dataFolder = 'data/'; const dataFolder = "data/";
class Database { class Database {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._sql = new sqlite.Database(dataFolder + 'database.sql') this._sql = new sqlite.Database(dataFolder + "database.sql");
} }
getDailyDatabase () { getDailyDatabase() {
if (!config.createDailyDatabaseCopy) { if (!config.createDailyDatabaseCopy) {
return return;
} }
const date = new Date() const date = new Date();
const fileName = `database_copy_${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}.sql` const fileName = `database_copy_${date.getDate()}-${
date.getMonth() + 1
}-${date.getFullYear()}.sql`;
if (fileName !== this._currentDatabaseCopyFileName) { if (fileName !== this._currentDatabaseCopyFileName) {
if (this._currentDatabaseCopyInstance) { if (this._currentDatabaseCopyInstance) {
this._currentDatabaseCopyInstance.close() this._currentDatabaseCopyInstance.close();
} }
this._currentDatabaseCopyInstance = new sqlite.Database(dataFolder + fileName) this._currentDatabaseCopyInstance = new sqlite.Database(
this._currentDatabaseCopyFileName = fileName dataFolder + fileName
);
this._currentDatabaseCopyFileName = fileName;
// Ensure the initial tables are created // Ensure the initial tables are created
// This does not created indexes since it is only inserted to // This does not created indexes since it is only inserted to
this._currentDatabaseCopyInstance.serialize(() => { this._currentDatabaseCopyInstance.serialize(() => {
this._currentDatabaseCopyInstance.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)', err => { this._currentDatabaseCopyInstance.run(
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
(err) => {
if (err) { if (err) {
logger.log('error', 'Cannot create initial table for daily database') logger.log(
throw err "error",
"Cannot create initial table for daily database"
);
throw err;
} }
}) }
}) );
});
} }
return this._currentDatabaseCopyInstance return this._currentDatabaseCopyInstance;
} }
ensureIndexes (callback) { ensureIndexes(callback) {
const handleError = err => { const handleError = (err) => {
if (err) { if (err) {
logger.log('error', 'Cannot create table or table index') logger.log("error", "Cannot create table or table index");
throw err throw err;
}
} }
};
this._sql.serialize(() => { this._sql.serialize(() => {
this._sql.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)', handleError) this._sql.run(
this._sql.run('CREATE TABLE IF NOT EXISTS players_record (timestamp BIGINT, ip TINYTEXT NOT NULL PRIMARY KEY, playerCount MEDIUMINT)', handleError) "CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
this._sql.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)', handleError) handleError
this._sql.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)', [], err => { );
handleError(err) this._sql.run(
"CREATE TABLE IF NOT EXISTS players_record (timestamp BIGINT, ip TINYTEXT NOT NULL PRIMARY KEY, playerCount MEDIUMINT)",
handleError
);
this._sql.run(
"CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)",
handleError
);
this._sql.run(
"CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)",
[],
(err) => {
handleError(err);
// Queries are executed one at a time; this is the last one. // Queries are executed one at a time; this is the last one.
// Note that queries not scheduled directly in the callback function of // Note that queries not scheduled directly in the callback function of
// #serialize are not necessarily serialized. // #serialize are not necessarily serialized.
callback() callback();
}) }
}) );
});
} }
loadGraphPoints (graphDuration, callback) { loadGraphPoints(graphDuration, callback) {
// Query recent pings // Query recent pings
const endTime = TimeTracker.getEpochMillis() const endTime = TimeTracker.getEpochMillis();
const startTime = endTime - graphDuration const startTime = endTime - graphDuration;
this.getRecentPings(startTime, endTime, pingData => { this.getRecentPings(startTime, endTime, (pingData) => {
const relativeGraphData = [] const relativeGraphData = [];
for (const row of pingData) { for (const row of pingData) {
// Load into temporary array // Load into temporary array
// This will be culled prior to being pushed to the serverRegistration // This will be culled prior to being pushed to the serverRegistration
let graphData = relativeGraphData[row.ip] let graphData = relativeGraphData[row.ip];
if (!graphData) { if (!graphData) {
relativeGraphData[row.ip] = graphData = [[], []] relativeGraphData[row.ip] = graphData = [[], []];
} }
// DANGER! // DANGER!
// This will pull the timestamp from each row into memory // This will pull the timestamp from each row into memory
// This is built under the assumption that each round of pings shares the same timestamp // This is built under the assumption that each round of pings shares the same timestamp
// This enables all timestamp arrays to have consistent point selection and graph correctly // This enables all timestamp arrays to have consistent point selection and graph correctly
graphData[0].push(row.timestamp) graphData[0].push(row.timestamp);
graphData[1].push(row.playerCount) graphData[1].push(row.playerCount);
} }
Object.keys(relativeGraphData).forEach(ip => { Object.keys(relativeGraphData).forEach((ip) => {
// Match IPs to serverRegistration object // Match IPs to serverRegistration object
for (const serverRegistration of this._app.serverRegistrations) { for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.data.ip === ip) { if (serverRegistration.data.ip === ip) {
const graphData = relativeGraphData[ip] const graphData = relativeGraphData[ip];
// Push the data into the instance and cull if needed // Push the data into the instance and cull if needed
serverRegistration.loadGraphPoints(startTime, graphData[0], graphData[1]) serverRegistration.loadGraphPoints(
startTime,
graphData[0],
graphData[1]
);
break break;
} }
} }
}) });
// Since all timestamps are shared, use the array from the first ServerRegistration // Since all timestamps are shared, use the array from the first ServerRegistration
// This is very dangerous and can break if data is out of sync // This is very dangerous and can break if data is out of sync
if (Object.keys(relativeGraphData).length > 0) { if (Object.keys(relativeGraphData).length > 0) {
const serverIp = Object.keys(relativeGraphData)[0] const serverIp = Object.keys(relativeGraphData)[0];
const timestamps = relativeGraphData[serverIp][0] const timestamps = relativeGraphData[serverIp][0];
this._app.timeTracker.loadGraphPoints(startTime, timestamps) this._app.timeTracker.loadGraphPoints(startTime, timestamps);
} }
callback() callback();
}) });
} }
loadRecords (callback) { loadRecords(callback) {
let completedTasks = 0 let completedTasks = 0;
this._app.serverRegistrations.forEach(serverRegistration => { this._app.serverRegistrations.forEach((serverRegistration) => {
// Find graphPeaks // Find graphPeaks
// This pre-computes the values prior to clients connecting // This pre-computes the values prior to clients connecting
serverRegistration.findNewGraphPeak() serverRegistration.findNewGraphPeak();
// Query recordData // Query recordData
// When complete increment completeTasks to know when complete // When complete increment completeTasks to know when complete
this.getRecord(serverRegistration.data.ip, (hasRecord, playerCount, timestamp) => { this.getRecord(
serverRegistration.data.ip,
(hasRecord, playerCount, timestamp) => {
if (hasRecord) { if (hasRecord) {
serverRegistration.recordData = { serverRegistration.recordData = {
playerCount, playerCount,
timestamp: TimeTracker.toSeconds(timestamp) timestamp: TimeTracker.toSeconds(timestamp),
} };
} else { } else {
this.getRecordLegacy(serverRegistration.data.ip, (hasRecordLegacy, playerCountLegacy, timestampLegacy) => { this.getRecordLegacy(
serverRegistration.data.ip,
(hasRecordLegacy, playerCountLegacy, timestampLegacy) => {
// New values that will be inserted to table // New values that will be inserted to table
let newTimestamp = null let newTimestamp = null;
let newPlayerCount = null let newPlayerCount = null;
// If legacy record found, use it for insertion // If legacy record found, use it for insertion
if (hasRecordLegacy) { if (hasRecordLegacy) {
newTimestamp = timestampLegacy newTimestamp = timestampLegacy;
newPlayerCount = playerCountLegacy newPlayerCount = playerCountLegacy;
} }
// Set record to recordData // Set record to recordData
serverRegistration.recordData = { serverRegistration.recordData = {
playerCount: newPlayerCount, playerCount: newPlayerCount,
timestamp: TimeTracker.toSeconds(newTimestamp) timestamp: TimeTracker.toSeconds(newTimestamp),
} };
// Insert server entry to records table // Insert server entry to records table
const statement = this._sql.prepare('INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)') const statement = this._sql.prepare(
statement.run(newTimestamp, serverRegistration.data.ip, newPlayerCount, err => { "INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(
newTimestamp,
serverRegistration.data.ip,
newPlayerCount,
(err) => {
if (err) { if (err) {
logger.error(`Cannot insert initial player count record of ${serverRegistration.data.ip}`) logger.error(
throw err `Cannot insert initial player count record of ${serverRegistration.data.ip}`
);
throw err;
} }
}) }
statement.finalize() );
}) statement.finalize();
}
);
} }
// Check if completedTasks hit the finish value // Check if completedTasks hit the finish value
// Fire callback since #readyDatabase is complete // Fire callback since #readyDatabase is complete
if (++completedTasks === this._app.serverRegistrations.length) { if (++completedTasks === this._app.serverRegistrations.length) {
callback() callback();
} }
}) }
}) );
});
} }
getRecentPings (startTime, endTime, callback) { getRecentPings(startTime, endTime, callback) {
this._sql.all('SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?', [ this._sql.all(
startTime, "SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?",
endTime [startTime, endTime],
], (err, data) => { (err, data) => {
if (err) { if (err) {
logger.log('error', 'Cannot get recent pings') logger.log("error", "Cannot get recent pings");
throw err throw err;
} }
callback(data) callback(data);
}) }
);
} }
getRecord (ip, callback) { getRecord(ip, callback) {
this._sql.all('SELECT playerCount, timestamp FROM players_record WHERE ip = ?', [ this._sql.all(
ip "SELECT playerCount, timestamp FROM players_record WHERE ip = ?",
], (err, data) => { [ip],
(err, data) => {
if (err) { if (err) {
logger.log('error', `Cannot get ping record for ${ip}`) logger.log("error", `Cannot get ping record for ${ip}`);
throw err throw err;
} }
// Record not found // Record not found
if (data[0] === undefined) { if (data[0] === undefined) {
// eslint-disable-next-line node/no-callback-literal // eslint-disable-next-line node/no-callback-literal
callback(false) callback(false);
return return;
} }
const playerCount = data[0].playerCount const playerCount = data[0].playerCount;
const timestamp = data[0].timestamp const timestamp = data[0].timestamp;
// Allow null player counts and timestamps, the frontend will safely handle them // Allow null player counts and timestamps, the frontend will safely handle them
// eslint-disable-next-line node/no-callback-literal // eslint-disable-next-line node/no-callback-literal
callback(true, playerCount, timestamp) callback(true, playerCount, timestamp);
}) }
);
} }
// Retrieves record from pings table, used for converting to separate table // Retrieves record from pings table, used for converting to separate table
getRecordLegacy (ip, callback) { getRecordLegacy(ip, callback) {
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [ this._sql.all(
ip "SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?",
], (err, data) => { [ip],
(err, data) => {
if (err) { if (err) {
logger.log('error', `Cannot get legacy ping record for ${ip}`) logger.log("error", `Cannot get legacy ping record for ${ip}`);
throw err throw err;
} }
// For empty results, data will be length 1 with [null, null] // For empty results, data will be length 1 with [null, null]
const playerCount = data[0]['MAX(playerCount)'] const playerCount = data[0]["MAX(playerCount)"];
const timestamp = data[0].timestamp const timestamp = data[0].timestamp;
// Allow null timestamps, the frontend will safely handle them // Allow null timestamps, the frontend will safely handle them
// This allows insertion of free standing records without a known timestamp // This allows insertion of free standing records without a known timestamp
if (playerCount !== null) { if (playerCount !== null) {
// eslint-disable-next-line node/no-callback-literal // eslint-disable-next-line node/no-callback-literal
callback(true, playerCount, timestamp) callback(true, playerCount, timestamp);
} else { } else {
// eslint-disable-next-line node/no-callback-literal // eslint-disable-next-line node/no-callback-literal
callback(false) callback(false);
} }
}) }
);
} }
insertPing (ip, timestamp, unsafePlayerCount) { insertPing(ip, timestamp, unsafePlayerCount) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql) this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql);
// Push a copy of the data into the database copy, if any // Push a copy of the data into the database copy, if any
// This creates an "insert only" copy of the database for archiving // This creates an "insert only" copy of the database for archiving
const dailyDatabase = this.getDailyDatabase() const dailyDatabase = this.getDailyDatabase();
if (dailyDatabase) { if (dailyDatabase) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase) this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase);
} }
} }
_insertPingTo (ip, timestamp, unsafePlayerCount, db) { _insertPingTo(ip, timestamp, unsafePlayerCount, db) {
const statement = db.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)') const statement = db.prepare(
statement.run(timestamp, ip, unsafePlayerCount, err => { "INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(timestamp, ip, unsafePlayerCount, (err) => {
if (err) { if (err) {
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`) logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`);
throw err throw err;
} }
}) });
statement.finalize() statement.finalize();
} }
updatePlayerCountRecord (ip, playerCount, timestamp) { updatePlayerCountRecord(ip, playerCount, timestamp) {
const statement = this._sql.prepare('UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?') const statement = this._sql.prepare(
statement.run(timestamp, playerCount, ip, err => { "UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?"
);
statement.run(timestamp, playerCount, ip, (err) => {
if (err) { if (err) {
logger.error(`Cannot update player count record of ${ip} at ${timestamp}`) logger.error(
throw err `Cannot update player count record of ${ip} at ${timestamp}`
);
throw err;
} }
}) });
statement.finalize() statement.finalize();
} }
initOldPingsDelete (callback) { initOldPingsDelete(callback) {
// Delete old pings on startup // Delete old pings on startup
logger.info('Deleting old pings..') logger.info("Deleting old pings..");
this.deleteOldPings(() => { this.deleteOldPings(() => {
const oldPingsCleanupInterval = config.oldPingsCleanup.interval || 3600000 const oldPingsCleanupInterval =
config.oldPingsCleanup.interval || 3600000;
if (oldPingsCleanupInterval > 0) { if (oldPingsCleanupInterval > 0) {
// Delete old pings periodically // Delete old pings periodically
setInterval(() => this.deleteOldPings(), oldPingsCleanupInterval) setInterval(() => this.deleteOldPings(), oldPingsCleanupInterval);
} }
callback() callback();
}) });
} }
deleteOldPings (callback) { deleteOldPings(callback) {
// The oldest timestamp that will be kept // The oldest timestamp that will be kept
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration;
const deleteStart = TimeTracker.getEpochMillis() const deleteStart = TimeTracker.getEpochMillis();
const statement = this._sql.prepare('DELETE FROM pings WHERE timestamp < ?;') const statement = this._sql.prepare(
statement.run(oldestTimestamp, err => { "DELETE FROM pings WHERE timestamp < ?;"
);
statement.run(oldestTimestamp, (err) => {
if (err) { if (err) {
logger.error('Cannot delete old pings') logger.error("Cannot delete old pings");
throw err throw err;
} else { } else {
const deleteTook = TimeTracker.getEpochMillis() - deleteStart const deleteTook = TimeTracker.getEpochMillis() - deleteStart;
logger.info(`Old pings deleted in ${deleteTook}ms`) logger.info(`Old pings deleted in ${deleteTook}ms`);
if (callback) { if (callback) {
callback() callback();
} }
} }
}) });
statement.finalize() statement.finalize();
} }
} }
module.exports = Database module.exports = Database;

@ -1,78 +1,97 @@
const dns = require('dns') const dns = require("dns");
const logger = require('./logger') const logger = require("./logger");
const { TimeTracker } = require('./time') const { TimeTracker } = require("./time");
const config = require('../config') const config = require("../config");
const SKIP_SRV_TIMEOUT = config.skipSrvTimeout || 60 * 60 * 1000 const SKIP_SRV_TIMEOUT = config.skipSrvTimeout || 60 * 60 * 1000;
class DNSResolver { class DNSResolver {
constructor (ip, port) { constructor(ip, port) {
this._ip = ip this._ip = ip;
this._port = port this._port = port;
} }
_skipSrv () { _skipSrv() {
this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT;
} }
_isSkipSrv () { _isSkipSrv() {
return this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil return (
this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil
);
} }
resolve (callback) { resolve(callback) {
if (this._isSkipSrv()) { if (this._isSkipSrv()) {
callback(this._ip, this._port, config.rates.connectTimeout) callback(this._ip, this._port, config.rates.connectTimeout);
return return;
} }
const startTime = TimeTracker.getEpochMillis() const startTime = TimeTracker.getEpochMillis();
let callbackFired = false let callbackFired = false;
const fireCallback = (ip, port) => { const fireCallback = (ip, port) => {
if (!callbackFired) { if (!callbackFired) {
callbackFired = true callbackFired = true;
// Send currentTime - startTime to provide remaining connectionTime allowance // Send currentTime - startTime to provide remaining connectionTime allowance
const remainingTime = config.rates.connectTimeout - (TimeTracker.getEpochMillis() - startTime) const remainingTime =
config.rates.connectTimeout -
(TimeTracker.getEpochMillis() - startTime);
callback(ip || this._ip, port || this._port, remainingTime) callback(ip || this._ip, port || this._port, remainingTime);
}
} }
};
const timeoutCallback = setTimeout(fireCallback, config.rates.connectTimeout) const timeoutCallback = setTimeout(
fireCallback,
config.rates.connectTimeout
);
dns.resolveSrv('_minecraft._tcp.' + this._ip, (err, records) => { dns.resolveSrv("_minecraft._tcp." + this._ip, (err, records) => {
// Cancel the timeout handler if not already fired // Cancel the timeout handler if not already fired
if (!callbackFired) { if (!callbackFired) {
clearTimeout(timeoutCallback) clearTimeout(timeoutCallback);
} }
// Test if the error indicates a miss, or if the records returned are empty // Test if the error indicates a miss, or if the records returned are empty
if ((err && (err.code === 'ENOTFOUND' || err.code === 'ENODATA')) || !records || records.length === 0) { if (
(err && (err.code === "ENOTFOUND" || err.code === "ENODATA")) ||
!records ||
records.length === 0
) {
// Compare config.skipSrvTimeout directly since SKIP_SRV_TIMEOUT has an or'd value // Compare config.skipSrvTimeout directly since SKIP_SRV_TIMEOUT has an or'd value
// isSkipSrvTimeoutDisabled == whether the config has a valid skipSrvTimeout value set // isSkipSrvTimeoutDisabled == whether the config has a valid skipSrvTimeout value set
const isSkipSrvTimeoutDisabled = typeof config.skipSrvTimeout === 'number' && config.skipSrvTimeout === 0 const isSkipSrvTimeoutDisabled =
typeof config.skipSrvTimeout === "number" &&
config.skipSrvTimeout === 0;
// Only activate _skipSrv if the skipSrvTimeout value is either NaN or > 0 // Only activate _skipSrv if the skipSrvTimeout value is either NaN or > 0
// 0 represents a disabled flag // 0 represents a disabled flag
if (!this._isSkipSrv() && !isSkipSrvTimeoutDisabled) { if (!this._isSkipSrv() && !isSkipSrvTimeoutDisabled) {
this._skipSrv() this._skipSrv();
logger.log('warn', 'No SRV records were resolved for %s. Minetrack will skip attempting to resolve %s SRV records for %d minutes.', this._ip, this._ip, SKIP_SRV_TIMEOUT / (60 * 1000)) logger.log(
"warn",
"No SRV records were resolved for %s. Minetrack will skip attempting to resolve %s SRV records for %d minutes.",
this._ip,
this._ip,
SKIP_SRV_TIMEOUT / (60 * 1000)
);
} }
fireCallback() fireCallback();
} else { } else {
// Only fires if !err && records.length > 0 // Only fires if !err && records.length > 0
fireCallback(records[0].name, records[0].port) fireCallback(records[0].name, records[0].port);
} }
}) });
} }
} }
module.exports = DNSResolver module.exports = DNSResolver;

@ -1,17 +1,17 @@
const winston = require('winston') const winston = require("winston");
winston.remove(winston.transports.Console) winston.remove(winston.transports.Console);
winston.add(winston.transports.File, { winston.add(winston.transports.File, {
filename: 'minetrack.log' filename: "minetrack.log",
}) });
winston.add(winston.transports.Console, { winston.add(winston.transports.Console, {
timestamp: () => { timestamp: () => {
const date = new Date() const date = new Date();
return date.toLocaleTimeString() + ' ' + date.toLocaleDateString() return date.toLocaleTimeString() + " " + date.toLocaleDateString();
}, },
colorize: true colorize: true,
}) });
module.exports = winston module.exports = winston;

@ -1,6 +1,6 @@
module.exports = function MessageOf (name, data) { module.exports = function MessageOf(name, data) {
return JSON.stringify({ return JSON.stringify({
message: name, message: name,
...data ...data,
}) });
} };

@ -1,159 +1,212 @@
const minecraftJavaPing = require('mcping-js') const minecraftJavaPing = require("mcping-js");
const minecraftBedrockPing = require('mcpe-ping-fixed') const minecraftBedrockPing = require("mcpe-ping-fixed");
const logger = require('./logger') const logger = require("./logger");
const MessageOf = require('./message') const MessageOf = require("./message");
const { TimeTracker } = require('./time') const { TimeTracker } = require("./time");
const { getPlayerCountOrNull } = require('./util') const { getPlayerCountOrNull } = require("./util");
const config = require('../config') const config = require("../config");
function ping (serverRegistration, timeout, callback, version) { function ping(serverRegistration, timeout, callback, version) {
switch (serverRegistration.data.type) { switch (serverRegistration.data.type) {
case 'PC': case "PC":
serverRegistration.dnsResolver.resolve((host, port, remainingTimeout) => { serverRegistration.dnsResolver.resolve((host, port, remainingTimeout) => {
const server = new minecraftJavaPing.MinecraftServer(host, port || 25565) const server = new minecraftJavaPing.MinecraftServer(
host,
port || 25565
);
server.ping(remainingTimeout, version, (err, res) => { server.ping(remainingTimeout, version, (err, res) => {
if (err) { if (err) {
callback(err) callback(err);
} else { } else {
const payload = { const payload = {
players: { players: {
online: capPlayerCount(serverRegistration.data.ip, parseInt(res.players.online)) online: capPlayerCount(
serverRegistration.data.ip,
parseInt(res.players.online)
),
}, },
version: parseInt(res.version.protocol) version: parseInt(res.version.protocol),
} };
// Ensure the returned favicon is a data URI // Ensure the returned favicon is a data URI
if (res.favicon && res.favicon.startsWith('data:image/')) { if (res.favicon && res.favicon.startsWith("data:image/")) {
payload.favicon = res.favicon payload.favicon = res.favicon;
} }
callback(null, payload) callback(null, payload);
} }
}) });
}) });
break break;
case 'PE': case "PE":
minecraftBedrockPing(serverRegistration.data.ip, serverRegistration.data.port || 19132, (err, res) => { minecraftBedrockPing(
serverRegistration.data.ip,
serverRegistration.data.port || 19132,
(err, res) => {
if (err) { if (err) {
callback(err) callback(err);
} else { } else {
callback(null, { callback(null, {
players: { players: {
online: capPlayerCount(serverRegistration.data.ip, parseInt(res.currentPlayers)) online: capPlayerCount(
serverRegistration.data.ip,
parseInt(res.currentPlayers)
),
},
});
} }
}) },
} timeout
}, timeout) );
break break;
default: default:
throw new Error('Unsupported type: ' + serverRegistration.data.type) throw new Error("Unsupported type: " + serverRegistration.data.type);
} }
} }
// player count can be up to 1^32-1, which is a massive scale and destroys browser performance when rendering graphs // player count can be up to 1^32-1, which is a massive scale and destroys browser performance when rendering graphs
// Artificially cap and warn to prevent propogating garbage // Artificially cap and warn to prevent propogating garbage
function capPlayerCount (host, playerCount) { function capPlayerCount(host, playerCount) {
const maxPlayerCount = 250000 const maxPlayerCount = 250000;
if (playerCount !== Math.min(playerCount, maxPlayerCount)) { if (playerCount !== Math.min(playerCount, maxPlayerCount)) {
logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount) logger.log(
"warn",
"%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!",
host,
playerCount,
maxPlayerCount
);
return maxPlayerCount return maxPlayerCount;
} else if (playerCount !== Math.max(playerCount, 0)) { } else if (playerCount !== Math.max(playerCount, 0)) {
logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount) logger.log(
"warn",
"%s returned an invalid player count of %d, setting to 0.",
host,
playerCount
);
return 0 return 0;
} }
return playerCount return playerCount;
} }
class PingController { class PingController {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._isRunningTasks = false this._isRunningTasks = false;
} }
schedule () { schedule() {
setInterval(this.pingAll, config.rates.pingAll) setInterval(this.pingAll, config.rates.pingAll);
this.pingAll() this.pingAll();
} }
pingAll = () => { pingAll = () => {
const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp() const { timestamp, updateHistoryGraph } =
this._app.timeTracker.newPointTimestamp();
this.startPingTasks(results => { this.startPingTasks((results) => {
const updates = [] const updates = [];
for (const serverRegistration of this._app.serverRegistrations) { for (const serverRegistration of this._app.serverRegistrations) {
const result = results[serverRegistration.serverId] const result = results[serverRegistration.serverId];
// Log to database if enabled // Log to database if enabled
// Use null to represent a failed ping // Use null to represent a failed ping
if (config.logToDatabase) { if (config.logToDatabase) {
const unsafePlayerCount = getPlayerCountOrNull(result.resp) const unsafePlayerCount = getPlayerCountOrNull(result.resp);
this._app.database.insertPing(serverRegistration.data.ip, timestamp, unsafePlayerCount) this._app.database.insertPing(
serverRegistration.data.ip,
timestamp,
unsafePlayerCount
);
} }
// Generate a combined update payload // Generate a combined update payload
// This includes any modified fields and flags used by the frontend // This includes any modified fields and flags used by the frontend
// This will not be cached and can contain live metadata // This will not be cached and can contain live metadata
const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version, updateHistoryGraph) const update = serverRegistration.handlePing(
timestamp,
result.resp,
result.err,
result.version,
updateHistoryGraph
);
updates[serverRegistration.serverId] = update updates[serverRegistration.serverId] = update;
} }
// Send object since updates uses serverIds as keys // Send object since updates uses serverIds as keys
// Send a single timestamp entry since it is shared // Send a single timestamp entry since it is shared
this._app.server.broadcast(MessageOf('updateServers', { this._app.server.broadcast(
MessageOf("updateServers", {
timestamp: TimeTracker.toSeconds(timestamp), timestamp: TimeTracker.toSeconds(timestamp),
updateHistoryGraph, updateHistoryGraph,
updates updates,
}))
}) })
} );
});
};
startPingTasks = (callback) => { startPingTasks = (callback) => {
if (this._isRunningTasks) { if (this._isRunningTasks) {
logger.log('warn', 'Started re-pinging servers before the last loop has finished! You may need to increase "rates.pingAll" in config.json') logger.log(
"warn",
'Started re-pinging servers before the last loop has finished! You may need to increase "rates.pingAll" in config.json'
);
return return;
} }
this._isRunningTasks = true this._isRunningTasks = true;
const results = [] const results = [];
for (const serverRegistration of this._app.serverRegistrations) { for (const serverRegistration of this._app.serverRegistrations) {
const version = serverRegistration.getNextProtocolVersion() const version = serverRegistration.getNextProtocolVersion();
ping(serverRegistration, config.rates.connectTimeout, (err, resp) => { ping(
serverRegistration,
config.rates.connectTimeout,
(err, resp) => {
if (err && config.logFailedPings !== false) { if (err && config.logFailedPings !== false) {
logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message) logger.log(
"error",
"Failed to ping %s: %s",
serverRegistration.data.ip,
err.message
);
} }
results[serverRegistration.serverId] = { results[serverRegistration.serverId] = {
resp, resp,
err, err,
version version,
} };
if (Object.keys(results).length === this._app.serverRegistrations.length) { if (
Object.keys(results).length === this._app.serverRegistrations.length
) {
// Loop has completed, release the locking flag // Loop has completed, release the locking flag
this._isRunningTasks = false this._isRunningTasks = false;
callback(results) callback(results);
}
}, version.protocolId)
} }
},
version.protocolId
);
} }
};
} }
module.exports = PingController module.exports = PingController;

@ -1,114 +1,139 @@
const http = require('http') const http = require("http");
const format = require('util').format const format = require("util").format;
const WebSocket = require('ws') const WebSocket = require("ws");
const finalHttpHandler = require('finalhandler') const finalHttpHandler = require("finalhandler");
const serveStatic = require('serve-static') const serveStatic = require("serve-static");
const logger = require('./logger') const logger = require("./logger");
const HASHED_FAVICON_URL_REGEX = /hashedfavicon_([a-z0-9]{32}).png/g const HASHED_FAVICON_URL_REGEX = /hashedfavicon_([a-z0-9]{32}).png/g;
function getRemoteAddr (req) { function getRemoteAddr(req) {
return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress return (
req.headers["cf-connecting-ip"] ||
req.headers["x-forwarded-for"] ||
req.connection.remoteAddress
);
} }
class Server { class Server {
static getHashedFaviconUrl (hash) { static getHashedFaviconUrl(hash) {
// Format must be compatible with HASHED_FAVICON_URL_REGEX // Format must be compatible with HASHED_FAVICON_URL_REGEX
return format('/hashedfavicon_%s.png', hash) return format("/hashedfavicon_%s.png", hash);
} }
constructor (app) { constructor(app) {
this._app = app this._app = app;
this.createHttpServer() this.createHttpServer();
this.createWebSocketServer() this.createWebSocketServer();
} }
createHttpServer () { createHttpServer() {
const distServeStatic = serveStatic('dist/') const distServeStatic = serveStatic("dist/");
const faviconsServeStatic = serveStatic('favicons/') const faviconsServeStatic = serveStatic("favicons/");
this._http = http.createServer((req, res) => { this._http = http.createServer((req, res) => {
logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url) logger.log("info", "%s requested: %s", getRemoteAddr(req), req.url);
// Test the URL against a regex for hashed favicon URLs // Test the URL against a regex for hashed favicon URLs
// Require only 1 match ([0]) and test its first captured group ([1]) // Require only 1 match ([0]) and test its first captured group ([1])
// Any invalid value or hit miss will pass into static handlers below // Any invalid value or hit miss will pass into static handlers below
const faviconHash = [...req.url.matchAll(HASHED_FAVICON_URL_REGEX)] const faviconHash = [...req.url.matchAll(HASHED_FAVICON_URL_REGEX)];
if (faviconHash.length === 1 && this.handleFaviconRequest(res, faviconHash[0][1])) { if (
return faviconHash.length === 1 &&
this.handleFaviconRequest(res, faviconHash[0][1])
) {
return;
} }
// Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic // Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic
// If faviconServeStatic fails, pass to finalHttpHandler to terminate // If faviconServeStatic fails, pass to finalHttpHandler to terminate
distServeStatic(req, res, () => { distServeStatic(req, res, () => {
faviconsServeStatic(req, res, finalHttpHandler(req, res)) faviconsServeStatic(req, res, finalHttpHandler(req, res));
}) });
}) });
} }
handleFaviconRequest = (res, faviconHash) => { handleFaviconRequest = (res, faviconHash) => {
for (const serverRegistration of this._app.serverRegistrations) { for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.faviconHash && serverRegistration.faviconHash === faviconHash) { if (
const buf = Buffer.from(serverRegistration.lastFavicon.split(',')[1], 'base64') serverRegistration.faviconHash &&
serverRegistration.faviconHash === faviconHash
) {
const buf = Buffer.from(
serverRegistration.lastFavicon.split(",")[1],
"base64"
);
res.writeHead(200, { res
'Content-Type': 'image/png', .writeHead(200, {
'Content-Length': buf.length, "Content-Type": "image/png",
'Cache-Control': 'public, max-age=604800' // Cache hashed favicon for 7 days "Content-Length": buf.length,
}).end(buf) "Cache-Control": "public, max-age=604800", // Cache hashed favicon for 7 days
return true
}
}
return false
}
createWebSocketServer () {
this._wss = new WebSocket.Server({
server: this._http
}) })
.end(buf);
this._wss.on('connection', (client, req) => { return true;
logger.log('info', '%s connected, total clients: %d', getRemoteAddr(req), this.getConnectedClients()) }
}
return false;
};
createWebSocketServer() {
this._wss = new WebSocket.Server({
server: this._http,
});
this._wss.on("connection", (client, req) => {
logger.log(
"info",
"%s connected, total clients: %d",
getRemoteAddr(req),
this.getConnectedClients()
);
// Bind disconnect event for logging // Bind disconnect event for logging
client.on('close', () => { client.on("close", () => {
logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(req), this.getConnectedClients()) logger.log(
}) "info",
"%s disconnected, total clients: %d",
getRemoteAddr(req),
this.getConnectedClients()
);
});
// Pass client off to proxy handler // Pass client off to proxy handler
this._app.handleClientConnection(client) this._app.handleClientConnection(client);
}) });
} }
listen (host, port) { listen(host, port) {
this._http.listen(port, host) this._http.listen(port, host);
logger.log('info', 'Started on %s:%d', host, port) logger.log("info", "Started on %s:%d", host, port);
} }
broadcast (payload) { broadcast(payload) {
this._wss.clients.forEach(client => { this._wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(payload) client.send(payload);
} }
}) });
} }
getConnectedClients () { getConnectedClients() {
let count = 0 let count = 0;
this._wss.clients.forEach(client => { this._wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
count++ count++;
} }
}) });
return count return count;
} }
} }
module.exports = Server module.exports = Server;

@ -1,263 +1,297 @@
const crypto = require('crypto') const crypto = require("crypto");
const DNSResolver = require('./dns') const DNSResolver = require("./dns");
const Server = require('./server') const Server = require("./server");
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time') const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require("./time");
const { getPlayerCountOrNull } = require('./util') const { getPlayerCountOrNull } = require("./util");
const config = require('../config') const config = require("../config");
const minecraftVersions = require('../minecraft_versions') const minecraftVersions = require("../minecraft_versions");
class ServerRegistration { class ServerRegistration {
serverId serverId;
lastFavicon lastFavicon;
versions = [] versions = [];
recordData recordData;
graphData = [] graphData = [];
constructor (app, serverId, data) { constructor(app, serverId, data) {
this._app = app this._app = app;
this.serverId = serverId this.serverId = serverId;
this.data = data this.data = data;
this._pingHistory = [] this._pingHistory = [];
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port) this.dnsResolver = new DNSResolver(this.data.ip, this.data.port);
} }
handlePing (timestamp, resp, err, version, updateHistoryGraph) { handlePing(timestamp, resp, err, version, updateHistoryGraph) {
// Use null to represent a failed ping // Use null to represent a failed ping
const unsafePlayerCount = getPlayerCountOrNull(resp) const unsafePlayerCount = getPlayerCountOrNull(resp);
// Store into in-memory ping data // Store into in-memory ping data
TimeTracker.pushAndShift(this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength()) TimeTracker.pushAndShift(
this._pingHistory,
unsafePlayerCount,
TimeTracker.getMaxServerGraphDataLength()
);
// Only notify the frontend to append to the historical graph // Only notify the frontend to append to the historical graph
// if both the graphing behavior is enabled and the backend agrees // if both the graphing behavior is enabled and the backend agrees
// that the ping is eligible for addition // that the ping is eligible for addition
if (updateHistoryGraph) { if (updateHistoryGraph) {
TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength()) TimeTracker.pushAndShift(
this.graphData,
unsafePlayerCount,
TimeTracker.getMaxGraphDataLength()
);
} }
// Delegate out update payload generation // Delegate out update payload generation
return this.getUpdate(timestamp, resp, err, version) return this.getUpdate(timestamp, resp, err, version);
} }
getUpdate (timestamp, resp, err, version) { getUpdate(timestamp, resp, err, version) {
const update = {} const update = {};
// Always append a playerCount value // Always append a playerCount value
// When resp is undefined (due to an error), playerCount will be null // When resp is undefined (due to an error), playerCount will be null
update.playerCount = getPlayerCountOrNull(resp) update.playerCount = getPlayerCountOrNull(resp);
if (resp) { if (resp) {
if (resp.version && this.updateProtocolVersionCompat(resp.version, version.protocolId, version.protocolIndex)) { if (
resp.version &&
this.updateProtocolVersionCompat(
resp.version,
version.protocolId,
version.protocolIndex
)
) {
// Append an updated version listing // Append an updated version listing
update.versions = this.versions update.versions = this.versions;
} }
if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) { if (
config.logToDatabase &&
(!this.recordData || resp.players.online > this.recordData.playerCount)
) {
this.recordData = { this.recordData = {
playerCount: resp.players.online, playerCount: resp.players.online,
timestamp: TimeTracker.toSeconds(timestamp) timestamp: TimeTracker.toSeconds(timestamp),
} };
// Append an updated recordData // Append an updated recordData
update.recordData = this.recordData update.recordData = this.recordData;
// Update record in database // Update record in database
this._app.database.updatePlayerCountRecord(this.data.ip, resp.players.online, timestamp) this._app.database.updatePlayerCountRecord(
this.data.ip,
resp.players.online,
timestamp
);
} }
if (this.updateFavicon(resp.favicon)) { if (this.updateFavicon(resp.favicon)) {
update.favicon = this.getFaviconUrl() update.favicon = this.getFaviconUrl();
} }
if (config.logToDatabase) { if (config.logToDatabase) {
// Update calculated graph peak regardless if the graph is being updated // Update calculated graph peak regardless if the graph is being updated
// This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes // This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes
if (this.findNewGraphPeak()) { if (this.findNewGraphPeak()) {
update.graphPeakData = this.getGraphPeak() update.graphPeakData = this.getGraphPeak();
} }
} }
} else if (err) { } else if (err) {
// Append a filtered copy of err // Append a filtered copy of err
// This ensures any unintended data is not leaked // This ensures any unintended data is not leaked
update.error = this.filterError(err) update.error = this.filterError(err);
} }
return update return update;
} }
getPingHistory () { getPingHistory() {
if (this._pingHistory.length > 0) { if (this._pingHistory.length > 0) {
const payload = { const payload = {
versions: this.versions, versions: this.versions,
recordData: this.recordData, recordData: this.recordData,
favicon: this.getFaviconUrl() favicon: this.getFaviconUrl(),
} };
// Only append graphPeakData if defined // Only append graphPeakData if defined
// The value is lazy computed and conditional that config->logToDatabase == true // The value is lazy computed and conditional that config->logToDatabase == true
const graphPeakData = this.getGraphPeak() const graphPeakData = this.getGraphPeak();
if (graphPeakData) { if (graphPeakData) {
payload.graphPeakData = graphPeakData payload.graphPeakData = graphPeakData;
} }
// Assume the ping was a success and define result // Assume the ping was a success and define result
// pingHistory does not keep error references, so its impossible to detect if this is an error // pingHistory does not keep error references, so its impossible to detect if this is an error
// It is also pointless to store that data since it will be short lived // It is also pointless to store that data since it will be short lived
payload.playerCount = this._pingHistory[this._pingHistory.length - 1] payload.playerCount = this._pingHistory[this._pingHistory.length - 1];
// Send a copy of pingHistory // Send a copy of pingHistory
// Include the last value even though it is contained within payload // Include the last value even though it is contained within payload
// The frontend will only push to its graphData from playerCountHistory // The frontend will only push to its graphData from playerCountHistory
payload.playerCountHistory = this._pingHistory payload.playerCountHistory = this._pingHistory;
return payload return payload;
} }
return { return {
error: { error: {
message: 'Pinging...' message: "Pinging...",
}, },
recordData: this.recordData, recordData: this.recordData,
graphPeakData: this.getGraphPeak(), graphPeakData: this.getGraphPeak(),
favicon: this.data.favicon favicon: this.data.favicon,
} };
} }
loadGraphPoints (startTime, timestamps, points) { loadGraphPoints(startTime, timestamps, points) {
this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i]) this.graphData = TimeTracker.everyN(
timestamps,
startTime,
GRAPH_UPDATE_TIME_GAP,
(i) => points[i]
);
} }
findNewGraphPeak () { findNewGraphPeak() {
let index = -1 let index = -1;
for (let i = 0; i < this.graphData.length; i++) { for (let i = 0; i < this.graphData.length; i++) {
const point = this.graphData[i] const point = this.graphData[i];
if (point !== null && (index === -1 || point > this.graphData[index])) { if (point !== null && (index === -1 || point > this.graphData[index])) {
index = i index = i;
} }
} }
if (index >= 0) { if (index >= 0) {
const lastGraphPeakIndex = this._graphPeakIndex const lastGraphPeakIndex = this._graphPeakIndex;
this._graphPeakIndex = index this._graphPeakIndex = index;
return index !== lastGraphPeakIndex return index !== lastGraphPeakIndex;
} else { } else {
this._graphPeakIndex = undefined this._graphPeakIndex = undefined;
return false return false;
} }
} }
getGraphPeak () { getGraphPeak() {
if (this._graphPeakIndex === undefined) { if (this._graphPeakIndex === undefined) {
return return;
} }
return { return {
playerCount: this.graphData[this._graphPeakIndex], playerCount: this.graphData[this._graphPeakIndex],
timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex) timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex),
} };
} }
updateFavicon (favicon) { updateFavicon(favicon) {
// If data.favicon is defined, then a favicon override is present // If data.favicon is defined, then a favicon override is present
// Disregard the incoming favicon, regardless if it is different // Disregard the incoming favicon, regardless if it is different
if (this.data.favicon) { if (this.data.favicon) {
return false return false;
} }
if (favicon && favicon !== this.lastFavicon) { if (favicon && favicon !== this.lastFavicon) {
this.lastFavicon = favicon this.lastFavicon = favicon;
// Generate an updated hash // Generate an updated hash
// This is used by #getFaviconUrl // This is used by #getFaviconUrl
this.faviconHash = crypto.createHash('md5').update(favicon).digest('hex').toString() this.faviconHash = crypto
.createHash("md5")
.update(favicon)
.digest("hex")
.toString();
return true return true;
} }
return false return false;
} }
getFaviconUrl () { getFaviconUrl() {
if (this.faviconHash) { if (this.faviconHash) {
return Server.getHashedFaviconUrl(this.faviconHash) return Server.getHashedFaviconUrl(this.faviconHash);
} else if (this.data.favicon) { } else if (this.data.favicon) {
return this.data.favicon return this.data.favicon;
} }
} }
updateProtocolVersionCompat (incomingId, outgoingId, protocolIndex) { updateProtocolVersionCompat(incomingId, outgoingId, protocolIndex) {
// If the result version matches the attempted version, the version is supported // If the result version matches the attempted version, the version is supported
const isSuccess = incomingId === outgoingId const isSuccess = incomingId === outgoingId;
const indexOf = this.versions.indexOf(protocolIndex) const indexOf = this.versions.indexOf(protocolIndex);
// Test indexOf to avoid inserting previously recorded protocolIndex values // Test indexOf to avoid inserting previously recorded protocolIndex values
if (isSuccess && indexOf < 0) { if (isSuccess && indexOf < 0) {
this.versions.push(protocolIndex) this.versions.push(protocolIndex);
// Sort versions in ascending order // Sort versions in ascending order
// This matches protocol ids to Minecraft versions release order // This matches protocol ids to Minecraft versions release order
this.versions.sort((a, b) => a - b) this.versions.sort((a, b) => a - b);
return true return true;
} else if (!isSuccess && indexOf >= 0) { } else if (!isSuccess && indexOf >= 0) {
this.versions.splice(indexOf, 1) this.versions.splice(indexOf, 1);
return true return true;
} }
return false return false;
} }
getNextProtocolVersion () { getNextProtocolVersion() {
// Minecraft Bedrock Edition does not have protocol versions // Minecraft Bedrock Edition does not have protocol versions
if (this.data.type === 'PE') { if (this.data.type === "PE") {
return { return {
protocolId: 0, protocolId: 0,
protocolIndex: 0 protocolIndex: 0,
};
} }
} const protocolVersions = minecraftVersions[this.data.type];
const protocolVersions = minecraftVersions[this.data.type] if (
if (typeof this._nextProtocolIndex === 'undefined' || this._nextProtocolIndex + 1 >= protocolVersions.length) { typeof this._nextProtocolIndex === "undefined" ||
this._nextProtocolIndex = 0 this._nextProtocolIndex + 1 >= protocolVersions.length
) {
this._nextProtocolIndex = 0;
} else { } else {
this._nextProtocolIndex++ this._nextProtocolIndex++;
} }
return { return {
protocolId: protocolVersions[this._nextProtocolIndex].protocolId, protocolId: protocolVersions[this._nextProtocolIndex].protocolId,
protocolIndex: this._nextProtocolIndex protocolIndex: this._nextProtocolIndex,
} };
} }
filterError (err) { filterError(err) {
let message = 'Unknown error' let message = "Unknown error";
// Attempt to match to the first possible value // Attempt to match to the first possible value
for (const key of ['message', 'description', 'errno']) { for (const key of ["message", "description", "errno"]) {
if (err[key]) { if (err[key]) {
message = err[key] message = err[key];
break break;
} }
} }
// Trim the message if too long // Trim the message if too long
if (message.length > 28) { if (message.length > 28) {
message = message.substring(0, 28) + '...' message = message.substring(0, 28) + "...";
} }
return { return {
message: message message: message,
} };
} }
getPublicData () { getPublicData() {
// Return a custom object instead of data directly to avoid data leakage // Return a custom object instead of data directly to avoid data leakage
return { return {
name: this.data.name, name: this.data.name,
ip: this.data.ip, ip: this.data.ip,
type: this.data.type, type: this.data.type,
color: this.data.color color: this.data.color,
} };
} }
} }
module.exports = ServerRegistration module.exports = ServerRegistration;

@ -1,96 +1,112 @@
const config = require('../config.json') const config = require("../config.json");
const GRAPH_UPDATE_TIME_GAP = 60 * 1000 // 60 seconds const GRAPH_UPDATE_TIME_GAP = 60 * 1000; // 60 seconds
class TimeTracker { class TimeTracker {
constructor (app) { constructor(app) {
this._app = app this._app = app;
this._serverGraphPoints = [] this._serverGraphPoints = [];
this._graphPoints = [] this._graphPoints = [];
} }
newPointTimestamp () { newPointTimestamp() {
const timestamp = TimeTracker.getEpochMillis() const timestamp = TimeTracker.getEpochMillis();
TimeTracker.pushAndShift(this._serverGraphPoints, timestamp, TimeTracker.getMaxServerGraphDataLength()) TimeTracker.pushAndShift(
this._serverGraphPoints,
timestamp,
TimeTracker.getMaxServerGraphDataLength()
);
// Flag each group as history graph additions each minute // Flag each group as history graph additions each minute
// This is sent to the frontend for graph updates // This is sent to the frontend for graph updates
const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP) const updateHistoryGraph =
config.logToDatabase &&
(!this._lastHistoryGraphUpdate ||
timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP);
if (updateHistoryGraph) { if (updateHistoryGraph) {
this._lastHistoryGraphUpdate = timestamp this._lastHistoryGraphUpdate = timestamp;
// Push into timestamps array to update backend state // Push into timestamps array to update backend state
TimeTracker.pushAndShift(this._graphPoints, timestamp, TimeTracker.getMaxGraphDataLength()) TimeTracker.pushAndShift(
this._graphPoints,
timestamp,
TimeTracker.getMaxGraphDataLength()
);
} }
return { return {
timestamp, timestamp,
updateHistoryGraph updateHistoryGraph,
} };
} }
loadGraphPoints (startTime, timestamps) { loadGraphPoints(startTime, timestamps) {
// This is a copy of ServerRegistration#loadGraphPoints // This is a copy of ServerRegistration#loadGraphPoints
// timestamps contains original timestamp data and needs to be filtered into minutes // timestamps contains original timestamp data and needs to be filtered into minutes
this._graphPoints = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => timestamps[i]) this._graphPoints = TimeTracker.everyN(
timestamps,
startTime,
GRAPH_UPDATE_TIME_GAP,
(i) => timestamps[i]
);
} }
getGraphPointAt (i) { getGraphPointAt(i) {
return TimeTracker.toSeconds(this._graphPoints[i]) return TimeTracker.toSeconds(this._graphPoints[i]);
} }
getServerGraphPoints () { getServerGraphPoints() {
return this._serverGraphPoints.map(TimeTracker.toSeconds) return this._serverGraphPoints.map(TimeTracker.toSeconds);
} }
getGraphPoints () { getGraphPoints() {
return this._graphPoints.map(TimeTracker.toSeconds) return this._graphPoints.map(TimeTracker.toSeconds);
} }
static toSeconds = (timestamp) => { static toSeconds = (timestamp) => {
return Math.floor(timestamp / 1000) return Math.floor(timestamp / 1000);
};
static getEpochMillis() {
return new Date().getTime();
} }
static getEpochMillis () { static getMaxServerGraphDataLength() {
return new Date().getTime() return Math.ceil(config.serverGraphDuration / config.rates.pingAll);
} }
static getMaxServerGraphDataLength () { static getMaxGraphDataLength() {
return Math.ceil(config.serverGraphDuration / config.rates.pingAll) return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP);
} }
static getMaxGraphDataLength () { static everyN(array, start, diff, adapter) {
return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP) const selected = [];
} let lastPoint = start;
static everyN (array, start, diff, adapter) {
const selected = []
let lastPoint = start
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
const point = array[i] const point = array[i];
if (point - lastPoint >= diff) { if (point - lastPoint >= diff) {
lastPoint = point lastPoint = point;
selected.push(adapter(i)) selected.push(adapter(i));
} }
} }
return selected return selected;
} }
static pushAndShift (array, value, maxLength) { static pushAndShift(array, value, maxLength) {
array.push(value) array.push(value);
if (array.length > maxLength) { if (array.length > maxLength) {
array.splice(0, array.length - maxLength) array.splice(0, array.length - maxLength);
} }
} }
} }
module.exports = { module.exports = {
GRAPH_UPDATE_TIME_GAP, GRAPH_UPDATE_TIME_GAP,
TimeTracker TimeTracker,
} };

@ -1,11 +1,11 @@
function getPlayerCountOrNull (resp) { function getPlayerCountOrNull(resp) {
if (resp) { if (resp) {
return resp.players.online return resp.players.online;
} else { } else {
return null return null;
} }
} }
module.exports = { module.exports = {
getPlayerCountOrNull getPlayerCountOrNull,
} };