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>
# 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).
### 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.
### Features
- 🚀 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 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.
### Community Showcase
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://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
- <https://mc.fascinated.cc/>
## Updates
For updates and release notes, please read the [CHANGELOG](docs/CHANGELOG.md).
**Migrating to Minetrack 5?** See the [migration guide](docs/MIGRATING.md).
## Installation
1. Node 12.4.0+ is required (you can check your version using `node -v`)
2. Make sure everything is correct in ```config.json```.
3. Add/remove servers by editing the ```servers.json``` file
4. Run ```npm install```
5. Run ```npm run build``` (this bundles `assets/` into `dist/`)
6. Run ```node main.js``` to boot the system (may need sudo!)
2. Make sure everything is correct in `config.json`.
3. Add/remove servers by editing the `servers.json` file
4. Run `npm install`
5. Run `npm run build` (this bundles `assets/` into `dist/`)
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.
## Docker
Minetrack can be built and run with Docker from this repository in several ways:
### Build and deploy directly with Docker
```
# build image with name minetrack and tag 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.:
* 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 start service
docker-compose up --build
@ -75,7 +76,9 @@ docker-compose down
```
## 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:
```
server {
server_name minetrack.example.net;

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

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

@ -1,48 +1,62 @@
<!DOCTYPE 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">
<link rel="icon" type="image/svg+xml" href="../images/logo.svg">
<meta charset="UTF-8">
<meta charset="UTF-8" />
<script defer type="module" src="../js/main.js"></script>
<title>Minetrack</title>
</head>
</head>
<body>
<body>
<div id="tooltip"></div>
<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>
<div id="status-text">Connecting...</div>
</div>
<div id="push">
<div id="perc-bar"></div>
<header>
<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>
<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 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>
</header>
@ -53,21 +67,29 @@
<div id="big-graph-checkboxes"></div>
<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="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>
<a minetrack-show-type="all" class="button graph-controls-show"
><span class="icon-eye"></span> Show All</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>
</div>
</div>
<div id="server-list"></div>
</div>
<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>
</body>
</body>
</html>

@ -1,100 +1,105 @@
import { ServerRegistry } from './servers'
import { SocketManager } from './socket'
import { SortController } from './sort'
import { GraphDisplayManager } from './graph'
import { PercentageBar } from './percbar'
import { FavoritesManager } from './favorites'
import { Tooltip, Caption, formatNumber } from './util'
import { FavoritesManager } from "./favorites";
import { GraphDisplayManager } from "./graph";
import { PercentageBar } from "./percbar";
import { ServerRegistry } from "./servers";
import { SocketManager } from "./socket";
import { SortController } from "./sort";
import { Caption, Tooltip, formatNumber } from "./util";
export class App {
publicConfig
publicConfig;
constructor () {
this.tooltip = new Tooltip()
this.caption = new Caption()
this.serverRegistry = new ServerRegistry(this)
this.socketManager = new SocketManager(this)
this.sortController = new SortController(this)
this.graphDisplayManager = new GraphDisplayManager(this)
this.percentageBar = new PercentageBar(this)
this.favoritesManager = new FavoritesManager(this)
constructor() {
this.tooltip = new Tooltip();
this.caption = new Caption();
this.serverRegistry = new ServerRegistry(this);
this.socketManager = new SocketManager(this);
this.sortController = new SortController(this);
this.graphDisplayManager = new GraphDisplayManager(this);
this.percentageBar = new PercentageBar(this);
this.favoritesManager = new FavoritesManager(this);
this._taskIds = []
this._taskIds = [];
}
// Called once the DOM is ready and the app can begin setup
init () {
this.socketManager.createWebSocket()
init() {
this.socketManager.createWebSocket();
}
setPageReady (isReady) {
document.getElementById('push').style.display = isReady ? 'block' : 'none'
document.getElementById('footer').style.display = isReady ? 'block' : 'none'
document.getElementById('status-overlay').style.display = isReady ? 'none' : 'block'
setPageReady(isReady) {
document.getElementById("push").style.display = isReady ? "block" : "none";
document.getElementById("footer").style.display = isReady
? "block"
: "none";
document.getElementById("status-overlay").style.display = isReady
? "none"
: "block";
}
setPublicConfig (publicConfig) {
this.publicConfig = publicConfig
setPublicConfig(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
// This simplifies management logic at the cost of each task needing to safely handle empty data
this.initTasks()
this.initTasks();
}
handleSyncComplete () {
this.caption.hide()
handleSyncComplete() {
this.caption.hide();
// 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
this.sortController.show()
this.percentageBar.redraw()
this.sortController.show();
this.percentageBar.redraw();
// The data may not be there to correctly compute values, but run an attempt
// Otherwise they will be updated by #initTasks
this.updateGlobalStats()
this.updateGlobalStats();
}
initTasks () {
this._taskIds.push(setInterval(this.sortController.sortServers, 5000))
initTasks() {
this._taskIds.push(setInterval(this.sortController.sortServers, 5000));
}
handleDisconnect () {
this.tooltip.hide()
handleDisconnect() {
this.tooltip.hide();
// Reset individual tracker elements to flush any held data
this.serverRegistry.reset()
this.socketManager.reset()
this.sortController.reset()
this.graphDisplayManager.reset()
this.percentageBar.reset()
this.serverRegistry.reset();
this.socketManager.reset();
this.sortController.reset();
this.graphDisplayManager.reset();
this.percentageBar.reset();
// Undefine publicConfig, resynced during the connection handshake
this.publicConfig = undefined
this.publicConfig = undefined;
// Clear all task ids, if any
this._taskIds.forEach(clearInterval)
this._taskIds.forEach(clearInterval);
this._taskIds = []
this._taskIds = [];
// Reset hidden values created by #updateGlobalStats
this._lastTotalPlayerCount = undefined
this._lastServerRegistrationCount = undefined
this._lastTotalPlayerCount = undefined;
this._lastServerRegistrationCount = undefined;
// Reset modified DOM structures
document.getElementById('stat_totalPlayers').innerText = 0
document.getElementById('stat_networks').innerText = 0
document.getElementById("stat_totalPlayers").innerText = 0;
document.getElementById("stat_networks").innerText = 0;
this.setPageReady(false)
this.setPageReady(false);
}
getTotalPlayerCount () {
return this.serverRegistry.getServerRegistrations()
.map(serverRegistration => serverRegistration.playerCount)
.reduce((sum, current) => sum + current, 0)
getTotalPlayerCount() {
return this.serverRegistry
.getServerRegistrations()
.map((serverRegistration) => serverRegistration.playerCount)
.reduce((sum, current) => sum + current, 0);
}
addServer = (serverId, payload, timestampPoints) => {
@ -102,51 +107,61 @@ export class App {
// result = undefined
// error = defined with "Waiting" description
// 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
// 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
// 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
// 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
serverRegistration.playerCount = payload.playerCount
serverRegistration.playerCount = payload.playerCount;
}
// 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
// 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
serverRegistration.initEventListeners()
}
serverRegistration.initEventListeners();
};
updateGlobalStats = () => {
// Only redraw when needed
// 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) {
this._lastTotalPlayerCount = totalPlayerCount
document.getElementById('stat_totalPlayers').innerText = formatNumber(totalPlayerCount)
this._lastTotalPlayerCount = totalPlayerCount;
document.getElementById("stat_totalPlayers").innerText =
formatNumber(totalPlayerCount);
}
// Only redraw when needed
// 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) {
this._lastServerRegistrationCount = serverRegistrationCount
document.getElementById('stat_networks').innerText = serverRegistrationCount
}
this._lastServerRegistrationCount = 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 {
constructor (app) {
this._app = app
constructor(app) {
this._app = app;
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
loadLocalStorage() {
if (typeof localStorage !== "undefined") {
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
if (serverNames) {
serverNames = JSON.parse(serverNames)
serverNames = JSON.parse(serverNames);
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
// Ensure serverRegistration is defined before mutating data or considering valid
if (serverRegistration) {
serverRegistration.isFavorite = true
serverRegistration.isFavorite = true;
// 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 () {
if (typeof localStorage !== 'undefined') {
updateLocalStorage() {
if (typeof localStorage !== "undefined") {
// Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => serverRegistration.isFavorite)
.map(serverRegistration => serverRegistration.data.name)
const serverNames = this._app.serverRegistry
.getServerRegistrations()
.filter((serverRegistration) => serverRegistration.isFavorite)
.map((serverRegistration) => serverRegistration.data.name);
if (serverNames.length > 0) {
// 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 {
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY)
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY);
}
}
}
handleFavoriteButtonClick = (serverRegistration) => {
serverRegistration.isFavorite = !serverRegistration.isFavorite
serverRegistration.isFavorite = !serverRegistration.isFavorite;
// 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
// 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
this.updateLocalStorage()
}
this.updateLocalStorage();
};
getIconClass (isFavorite) {
getIconClass(isFavorite) {
if (isFavorite) {
return 'icon-star server-is-favorite'
return "icon-star server-is-favorite";
} 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 SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites'
const HIDDEN_SERVERS_STORAGE_KEY = "minetrack_hidden_servers";
const SHOW_FAVORITES_STORAGE_KEY = "minetrack_show_favorites";
export class GraphDisplayManager {
constructor (app) {
this._app = app
this._graphData = []
this._graphTimestamps = []
this._hasLoadedSettings = false
this._initEventListenersOnce = false
this._showOnlyFavorites = false
constructor(app) {
this._app = app;
this._graphData = [];
this._graphTimestamps = [];
this._hasLoadedSettings = false;
this._initEventListenersOnce = false;
this._showOnlyFavorites = false;
}
addGraphPoint (timestamp, playerCounts) {
addGraphPoint(timestamp, playerCounts) {
if (!this._hasLoadedSettings) {
// _hasLoadedSettings is controlled by #setGraphData
// 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
// and the application has received updates prior to the initial state
return
return;
}
// Calculate isZoomed before mutating graphData otherwise the indexed values
// are out of date and will always fail when compared to plotScaleX.min/max
const plotScaleX = this._plotInstance.scales.x
const isZoomed = plotScaleX.min > this._graphTimestamps[0] || plotScaleX.max < this._graphTimestamps[this._graphTimestamps.length - 1]
const plotScaleX = this._plotInstance.scales.x;
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++) {
this._graphData[i].push(playerCounts[i])
this._graphData[i].push(playerCounts[i]);
}
// Trim all data arrays to only the relevant portion
// 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) {
this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength)
this._graphTimestamps.splice(
0,
this._graphTimestamps.length - graphMaxLength
);
}
for (const series of this._graphData) {
if (series.length > graphMaxLength) {
series.splice(0, series.length - graphMaxLength)
series.splice(0, series.length - graphMaxLength);
}
}
// Avoid redrawing the plot when zoomed
this._plotInstance.setData(this.getGraphData(), !isZoomed)
this._plotInstance.setData(this.getGraphData(), !isZoomed);
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY)
loadLocalStorage() {
if (typeof localStorage !== "undefined") {
const showOnlyFavorites = localStorage.getItem(
SHOW_FAVORITES_STORAGE_KEY
);
if (showOnlyFavorites) {
this._showOnlyFavorites = true
this._showOnlyFavorites = true;
}
// If only favorites mode is active, use the stored favorite servers data instead
let serverNames
let serverNames;
if (this._showOnlyFavorites) {
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
} else {
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY)
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY);
}
if (serverNames) {
serverNames = JSON.parse(serverNames)
serverNames = JSON.parse(serverNames);
// Iterate over all active serverRegistrations
// 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
// Checks between FAVORITE/HIDDEN keys are mutually exclusive
if (this._showOnlyFavorites) {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0
serverRegistration.isVisible =
serverNames.indexOf(serverRegistration.data.name) >= 0;
} else {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0
serverRegistration.isVisible =
serverNames.indexOf(serverRegistration.data.name) < 0;
}
}
}
}
}
updateLocalStorage () {
if (typeof localStorage !== 'undefined') {
updateLocalStorage() {
if (typeof localStorage !== "undefined") {
// Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => !serverRegistration.isVisible)
.map(serverRegistration => serverRegistration.data.name)
const serverNames = this._app.serverRegistry
.getServerRegistrations()
.filter((serverRegistration) => !serverRegistration.isVisible)
.map((serverRegistration) => serverRegistration.data.name);
// 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 (serverNames.length > 0 && !this._showOnlyFavorites) {
localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
localStorage.setItem(
HIDDEN_SERVERS_STORAGE_KEY,
JSON.stringify(serverNames)
);
} else {
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY)
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY);
}
// Only store SHOW_FAVORITES_STORAGE_KEY if true
if (this._showOnlyFavorites) {
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true)
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true);
} else {
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY)
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY);
}
}
}
getVisibleGraphData () {
return this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => serverRegistration.isVisible)
.map(serverRegistration => this._graphData[serverRegistration.serverId])
getVisibleGraphData() {
return this._app.serverRegistry
.getServerRegistrations()
.filter((serverRegistration) => serverRegistration.isVisible)
.map(
(serverRegistration) => this._graphData[serverRegistration.serverId]
);
}
getPlotSize () {
getPlotSize() {
return {
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 () {
return [
this._graphTimestamps,
...this._graphData
]
}
getClosestPlotSeriesIndex(idx) {
let closestSeriesIndex = -1;
let closestSeriesDist = Number.MAX_VALUE;
getGraphDataPoint (serverId, index) {
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
const plotHeight = this._plotInstance.bbox.height / devicePixelRatio;
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) {
continue
continue;
}
const point = this._plotInstance.data[i][idx]
const point = this._plotInstance.data[i][idx];
if (typeof point === 'number') {
const scale = this._plotInstance.scales[series.scale]
const posY = (1 - ((point - scale.min) / (scale.max - scale.min))) * plotHeight
if (typeof point === "number") {
const scale = this._plotInstance.scales[series.scale];
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) {
closestSeriesIndex = i
closestSeriesDist = dist
closestSeriesIndex = i;
closestSeriesDist = dist;
}
}
}
return closestSeriesIndex
return closestSeriesIndex;
}
buildPlotInstance (timestamps, data) {
buildPlotInstance(timestamps, data) {
// Lazy load settings from localStorage, if any and if enabled
if (!this._hasLoadedSettings) {
this._hasLoadedSettings = true
this._hasLoadedSettings = true;
this.loadLocalStorage()
this.loadLocalStorage();
}
for (const playerCounts of data) {
// 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
// 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) {
const padding = Array(lengthDiff).fill(null)
const padding = Array(lengthDiff).fill(null);
playerCounts.unshift(...padding)
playerCounts.unshift(...padding);
}
}
this._graphTimestamps = timestamps
this._graphData = data
this._graphTimestamps = timestamps;
this._graphData = data;
const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => {
const series = this._app.serverRegistry
.getServerRegistrations()
.map((serverRegistration) => {
return {
stroke: serverRegistration.data.color,
width: 2,
@ -206,261 +226,293 @@ export class GraphDisplayManager {
show: serverRegistration.isVisible,
spanGaps: true,
points: {
show: false
}
}
})
show: false,
},
};
});
const tickCount = 10
const maxFactor = 4
const tickCount = 10;
const maxFactor = 4;
// eslint-disable-next-line new-cap
this._plotInstance = new uPlot({
this._plotInstance = new uPlot(
{
plugins: [
uPlotTooltipPlugin((pos, idx) => {
if (pos) {
const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx)
const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx);
const text = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => serverRegistration.isVisible)
const text =
this._app.serverRegistry
.getServerRegistrations()
.filter((serverRegistration) => serverRegistration.isVisible)
.sort((a, b) => {
if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1
return a.isFavorite ? -1 : 1;
} else {
return a.data.name.localeCompare(b.data.name)
return a.data.name.localeCompare(b.data.name);
}
})
.map(serverRegistration => {
const point = this.getGraphDataPoint(serverRegistration.serverId, idx)
.map((serverRegistration) => {
const point = this.getGraphDataPoint(
serverRegistration.serverId,
idx
);
let serverName = serverRegistration.data.name
if (closestSeriesIndex === serverRegistration.getGraphDataIndex()) {
serverName = `<strong>${serverName}</strong>`
let serverName = serverRegistration.data.name;
if (
closestSeriesIndex ===
serverRegistration.getGraphDataIndex()
) {
serverName = `<strong>${serverName}</strong>`;
}
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)}`
}).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()
}
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();
}
}),
],
...this.getPlotSize(),
cursor: {
y: false
y: false,
},
series: [
{
},
...series
],
series: [{}, ...series],
axes: [
{
font: '14px "Open Sans", sans-serif',
stroke: '#FFF',
stroke: "#FFF",
grid: {
show: false
show: false,
},
space: 60
space: 60,
},
{
font: '14px "Open Sans", sans-serif',
stroke: '#FFF',
stroke: "#FFF",
size: 65,
grid: {
stroke: '#333',
width: 1
stroke: "#333",
width: 1,
},
split: () => {
const visibleGraphData = this.getVisibleGraphData()
const { scaledMax, scale } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor)
const ticks = RelativeScale.generateTicks(0, scaledMax, scale)
return ticks
}
}
const visibleGraphData = this.getVisibleGraphData();
const { scaledMax, scale } = RelativeScale.scaleMatrix(
visibleGraphData,
tickCount,
maxFactor
);
const ticks = RelativeScale.generateTicks(0, scaledMax, scale);
return ticks;
},
},
],
scales: {
y: {
auto: false,
range: () => {
const visibleGraphData = this.getVisibleGraphData()
const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor)
return [scaledMin, scaledMax]
}
}
const visibleGraphData = this.getVisibleGraphData();
const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(
visibleGraphData,
tickCount,
maxFactor
);
return [scaledMin, scaledMax];
},
},
},
legend: {
show: false
}
}, this.getGraphData(), document.getElementById('big-graph'))
show: false,
},
},
this.getGraphData(),
document.getElementById("big-graph")
);
// Show the settings-toggle element
document.getElementById('settings-toggle').style.display = 'inline-block'
document.getElementById("settings-toggle").style.display = "inline-block";
}
redraw = () => {
// Use drawing as a hint to update settings
// 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
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
// 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
if (this._plotInstance) {
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
clearTimeout(this._resizeRequestTimeout);
}
// Schedule new delayed resize call
// This can be cancelled by #requestResize, #resize and #reset
this._resizeRequestTimeout = setTimeout(this.resize, 200)
this._resizeRequestTimeout = setTimeout(this.resize, 200);
}
}
resize = () => {
this._plotInstance.setSize(this.getPlotSize())
this._plotInstance.setSize(this.getPlotSize());
// undefine value so #clearTimeout is not called
// This is safe even if #resize is manually called since it removes the pending work
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
clearTimeout(this._resizeRequestTimeout);
}
this._resizeRequestTimeout = undefined
}
this._resizeRequestTimeout = undefined;
};
initEventListeners () {
initEventListeners() {
if (!this._initEventListenersOnce) {
this._initEventListenersOnce = true
this._initEventListenersOnce = true;
// 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) => {
element.addEventListener('click', this.handleShowButtonClick, false)
})
document.querySelectorAll(".graph-controls-show").forEach((element) => {
element.addEventListener("click", this.handleShowButtonClick, false);
});
}
// These listeners should be bound each #initEventListeners call since they are for newly created elements
document.querySelectorAll('.graph-control').forEach((element) => {
element.addEventListener('click', this.handleServerButtonClick, false)
})
document.querySelectorAll(".graph-control").forEach((element) => {
element.addEventListener("click", this.handleServerButtonClick, false);
});
}
handleServerButtonClick = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
const serverId = parseInt(event.target.getAttribute("minetrack-server-id"));
const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverId);
if (serverRegistration.isVisible !== event.target.checked) {
serverRegistration.isVisible = event.target.checked
serverRegistration.isVisible = event.target.checked;
// Any manual changes automatically disables "Only Favorites" mode
// Otherwise the auto management might overwrite their manual changes
this._showOnlyFavorites = false
this._showOnlyFavorites = false;
this.redraw()
}
this.redraw();
}
};
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
// visible graphData is automatically updating when a ServerRegistration's #isVisible changes
// 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) {
let isVisible
if (showType === 'all') {
isVisible = true
} else if (showType === 'none') {
isVisible = false
} else if (showType === 'favorites') {
isVisible = serverRegistration.isFavorite
this._app.serverRegistry
.getServerRegistrations()
.forEach(function (serverRegistration) {
let isVisible;
if (showType === "all") {
isVisible = true;
} else if (showType === "none") {
isVisible = false;
} else if (showType === "favorites") {
isVisible = serverRegistration.isFavorite;
}
if (serverRegistration.isVisible !== isVisible) {
serverRegistration.isVisible = isVisible
redraw = true
serverRegistration.isVisible = isVisible;
redraw = true;
}
})
});
if (redraw) {
this.redraw()
this.updateCheckboxes()
}
this.redraw();
this.updateCheckboxes();
}
};
handleSettingsToggle = () => {
const element = document.getElementById('big-graph-controls-drawer')
const element = document.getElementById("big-graph-controls-drawer");
if (element.style.display !== 'block') {
element.style.display = 'block'
if (element.style.display !== "block") {
element.style.display = "block";
} else {
element.style.display = 'none'
}
element.style.display = "none";
}
};
handleServerIsFavoriteUpdate = (serverRegistration) => {
// When in "Only Favorites" mode, visibility is dependent on favorite status
// Redraw and update elements as needed
if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) {
serverRegistration.isVisible = serverRegistration.isFavorite
if (
this._showOnlyFavorites &&
serverRegistration.isVisible !== serverRegistration.isFavorite
) {
serverRegistration.isVisible = serverRegistration.isFavorite;
this.redraw()
this.updateCheckboxes()
this.redraw();
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 () {
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 () {
reset() {
// Destroy graphs and unload references
// uPlot#destroy handles listener de-registration, DOM reset, etc
if (this._plotInstance) {
this._plotInstance.destroy()
this._plotInstance = undefined
this._plotInstance.destroy();
this._plotInstance = undefined;
}
this._graphTimestamps = []
this._graphData = []
this._hasLoadedSettings = false
this._graphTimestamps = [];
this._graphData = [];
this._hasLoadedSettings = false;
// Fire #clearTimeout if the timeout is currently defined
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
clearTimeout(this._resizeRequestTimeout);
this._resizeRequestTimeout = undefined
this._resizeRequestTimeout = undefined;
}
// Reset modified DOM structures
document.getElementById('big-graph-checkboxes').innerHTML = ''
document.getElementById('big-graph-controls').style.display = 'none'
document.getElementById("big-graph-checkboxes").innerHTML = "";
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', () => {
app.init()
document.addEventListener(
"DOMContentLoaded",
() => {
app.init();
window.addEventListener('resize', function () {
app.percentageBar.redraw()
window.addEventListener(
"resize",
function () {
app.percentageBar.redraw();
// Delegate to GraphDisplayManager which can check if the resize is necessary
app.graphDisplayManager.requestResize()
}, false)
}, false)
app.graphDisplayManager.requestResize();
},
false
);
},
false
);

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

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

@ -1,88 +1,91 @@
export class RelativeScale {
static scale (data, tickCount, maxFactor) {
const { min, max } = RelativeScale.calculateBounds(data)
static scale(data, tickCount, maxFactor) {
const { min, max } = RelativeScale.calculateBounds(data);
let factor = 1
let factor = 1;
while (true) {
const scale = Math.pow(10, factor)
const scale = Math.pow(10, factor);
const scaledMin = min - (min % scale)
let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale))
const scaledMin = min - (min % scale);
let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale));
// Prevent min/max from being equal (and generating 0 ticks)
// This happens when all data points are products of scale value
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 {
scaledMin,
scaledMax,
scale
}
scale,
};
} else {
// Too many steps between min/max, increase factor and try again
factor++
factor++;
}
}
}
static scaleMatrix (data, tickCount, maxFactor) {
const nonNullData = data.flat().filter((val) => val !== null)
static scaleMatrix(data, tickCount, maxFactor) {
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
// 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
const max = nonNullData.reduce((a, b) => {
return Math.max(a, b)
}, Number.NEGATIVE_INFINITY)
return Math.max(a, b);
}, Number.NEGATIVE_INFINITY);
return RelativeScale.scale(
[0, RelativeScale.isFiniteOrZero(max)],
tickCount,
maxFactor
)
);
}
static generateTicks (min, max, step) {
const ticks = []
static generateTicks(min, max, step) {
const ticks = [];
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) {
return {
min: 0,
max: 0
}
max: 0,
};
} 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
// 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
const min = nonNullData.reduce((a, b) => {
return Math.min(a, b)
}, Number.POSITIVE_INFINITY)
return Math.min(a, b);
}, Number.POSITIVE_INFINITY);
const max = nonNullData.reduce((a, b) => {
return Math.max(a, b)
}, Number.NEGATIVE_INFINITY)
return Math.max(a, b);
}, Number.NEGATIVE_INFINITY);
return {
min: RelativeScale.isFiniteOrZero(min),
max: RelativeScale.isFiniteOrZero(max)
}
max: RelativeScale.isFiniteOrZero(max),
};
}
}
static isFiniteOrZero (val) {
return Number.isFinite(val) ? val : 0
static isFiniteOrZero(val) {
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 {
constructor (app) {
this._app = app
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
constructor(app) {
this._app = app;
this._serverIdsByName = [];
this._serverDataById = [];
this._registeredServers = [];
}
assignServers (servers) {
assignServers(servers) {
for (let i = 0; i < servers.length; i++) {
const data = servers[i]
this._serverIdsByName[data.name] = i
this._serverDataById[i] = data
const data = servers[i];
this._serverIdsByName[data.name] = i;
this._serverDataById[i] = data;
}
}
createServerRegistration (serverId) {
const serverData = this._serverDataById[serverId]
const serverRegistration = new ServerRegistration(this._app, serverId, serverData)
this._registeredServers[serverId] = serverRegistration
return serverRegistration
createServerRegistration(serverId) {
const serverData = this._serverDataById[serverId];
const serverRegistration = new ServerRegistration(
this._app,
serverId,
serverData
);
this._registeredServers[serverId] = serverRegistration;
return serverRegistration;
}
getServerRegistration (serverKey) {
if (typeof serverKey === 'string') {
const serverId = this._serverIdsByName[serverKey]
return this._registeredServers[serverId]
} else if (typeof serverKey === 'number') {
return this._registeredServers[serverKey]
getServerRegistration(serverKey) {
if (typeof serverKey === "string") {
const serverId = this._serverIdsByName[serverKey];
return this._registeredServers[serverId];
} else if (typeof serverKey === "number") {
return this._registeredServers[serverKey];
}
}
getServerRegistrations = () => Object.values(this._registeredServers)
getServerRegistrations = () => Object.values(this._registeredServers);
reset () {
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
reset() {
this._serverIdsByName = [];
this._serverDataById = [];
this._registeredServers = [];
// Reset modified DOM structures
document.getElementById('server-list').innerHTML = ''
document.getElementById("server-list").innerHTML = "";
}
}
export class ServerRegistration {
playerCount = 0
isVisible = true
isFavorite = false
rankIndex
lastRecordData
lastPeakData
playerCount = 0;
isVisible = true;
isFavorite = false;
rankIndex;
lastRecordData;
lastPeakData;
constructor (app, serverId, data) {
this._app = app
this.serverId = serverId
this.data = data
this._graphData = [[], []]
this._failedSequentialPings = 0
constructor(app, serverId, data) {
this._app = app;
this.serverId = serverId;
this.data = data;
this._graphData = [[], []];
this._failedSequentialPings = 0;
}
getGraphDataIndex () {
return this.serverId + 1
getGraphDataIndex() {
return this.serverId + 1;
}
addGraphPoints (points, timestampPoints) {
this._graphData = [
timestampPoints.slice(),
points
]
addGraphPoints(points, timestampPoints) {
this._graphData = [timestampPoints.slice(), points];
}
buildPlotInstance () {
const tickCount = 4
buildPlotInstance() {
const tickCount = 4;
// eslint-disable-next-line new-cap
this._plotInstance = new uPlot({
this._plotInstance = new uPlot(
{
plugins: [
uPlotTooltipPlugin((pos, id) => {
if (pos) {
const playerCount = this._graphData[1][id]
const playerCount = this._graphData[1][id];
if (typeof playerCount !== 'number') {
this._app.tooltip.hide()
if (typeof playerCount !== "number") {
this._app.tooltip.hide();
} 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 {
this._app.tooltip.hide()
this._app.tooltip.hide();
}
})
}),
],
height: 100,
width: 400,
@ -105,215 +123,274 @@ export class ServerRegistration {
drag: {
setScale: false,
x: false,
y: false
y: false,
},
sync: {
key: 'minetrack-server',
setSeries: true
}
key: "minetrack-server",
setSeries: true,
},
},
series: [
{},
{
stroke: '#E9E581',
stroke: "#E9E581",
width: 2,
value: (_, raw) => `${formatNumber(raw)} Players`,
spanGaps: true,
points: {
show: false
}
}
show: false,
},
},
],
axes: [
{
show: false
show: false,
},
{
ticks: {
show: false
show: false,
},
font: '14px "Open Sans", sans-serif',
stroke: '#A3A3A3',
stroke: "#A3A3A3",
size: 55,
grid: {
stroke: '#333',
width: 1
stroke: "#333",
width: 1,
},
split: () => {
const { scaledMin, scaledMax, scale } = RelativeScale.scale(this._graphData[1], tickCount)
const ticks = RelativeScale.generateTicks(scaledMin, scaledMax, scale)
return ticks
}
}
const { scaledMin, scaledMax, scale } = RelativeScale.scale(
this._graphData[1],
tickCount
);
const ticks = RelativeScale.generateTicks(
scaledMin,
scaledMax,
scale
);
return ticks;
},
},
],
scales: {
y: {
auto: false,
range: () => {
const { scaledMin, scaledMax } = RelativeScale.scale(this._graphData[1], tickCount)
return [scaledMin, scaledMax]
}
}
const { scaledMin, scaledMax } = RelativeScale.scale(
this._graphData[1],
tickCount
);
return [scaledMin, scaledMax];
},
},
},
legend: {
show: false
}
}, this._graphData, document.getElementById(`chart_${this.serverId}`))
show: false,
},
},
this._graphData,
document.getElementById(`chart_${this.serverId}`)
);
}
handlePing (payload, timestamp) {
if (typeof payload.playerCount === 'number') {
this.playerCount = payload.playerCount
handlePing(payload, timestamp) {
if (typeof payload.playerCount === "number") {
this.playerCount = payload.playerCount;
// Reset failed ping counter to ensure the next connection error
// doesn't instantly retrigger a layout change
this._failedSequentialPings = 0
this._failedSequentialPings = 0;
} else {
// 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
if (++this._failedSequentialPings > 5) {
this.playerCount = 0
this.playerCount = 0;
}
}
// Use payload.playerCount so nulls WILL be pushed into the graphing data
this._graphData[0].push(timestamp)
this._graphData[1].push(payload.playerCount)
this._graphData[0].push(timestamp);
this._graphData[1].push(payload.playerCount);
// Trim graphData to within the max length by shifting out the leading elements
for (const series of this._graphData) {
if (series.length > this._app.publicConfig.serverGraphMaxLength) {
series.shift()
series.shift();
}
}
// Redraw the plot instance
this._plotInstance.setData(this._graphData)
this._plotInstance.setData(this._graphData);
}
updateServerRankIndex (rankIndex) {
this.rankIndex = rankIndex
updateServerRankIndex(rankIndex) {
this.rankIndex = rankIndex;
document.getElementById(`ranking_${this.serverId}`).innerText = `#${rankIndex + 1}`
document.getElementById(`ranking_${this.serverId}`).innerText = `#${
rankIndex + 1
}`;
}
_renderValue (prefix, handler) {
const labelElement = document.getElementById(`${prefix}_${this.serverId}`)
_renderValue(prefix, handler) {
const labelElement = document.getElementById(`${prefix}_${this.serverId}`);
labelElement.style.display = 'block'
labelElement.style.display = "block";
const valueElement = document.getElementById(`${prefix}-value_${this.serverId}`)
const targetElement = valueElement || labelElement
const valueElement = document.getElementById(
`${prefix}-value_${this.serverId}`
);
const targetElement = valueElement || labelElement;
if (targetElement) {
if (typeof handler === 'function') {
handler(targetElement)
if (typeof handler === "function") {
handler(targetElement);
} else {
targetElement.innerText = handler
targetElement.innerText = handler;
}
}
}
_hideValue (prefix) {
const element = document.getElementById(`${prefix}_${this.serverId}`)
_hideValue(prefix) {
const element = document.getElementById(`${prefix}_${this.serverId}`);
element.style.display = 'none'
element.style.display = "none";
}
updateServerStatus (ping, minecraftVersions) {
updateServerStatus(ping, minecraftVersions) {
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) {
this._renderValue('record', (element) => {
this._renderValue("record", (element) => {
if (ping.recordData.timestamp > 0) {
element.innerText = `${formatNumber(ping.recordData.playerCount)} (${formatDate(ping.recordData.timestamp)})`
element.title = `At ${formatDate(ping.recordData.timestamp)} ${formatTimestampSeconds(ping.recordData.timestamp)}`
element.innerText = `${formatNumber(
ping.recordData.playerCount
)} (${formatDate(ping.recordData.timestamp)})`;
element.title = `At ${formatDate(
ping.recordData.timestamp
)} ${formatTimestampSeconds(ping.recordData.timestamp)}`;
} else {
element.innerText = formatNumber(ping.recordData.playerCount)
element.innerText = formatNumber(ping.recordData.playerCount);
}
})
});
this.lastRecordData = ping.recordData
this.lastRecordData = ping.recordData;
}
if (ping.graphPeakData) {
this._renderValue('peak', (element) => {
element.innerText = formatNumber(ping.graphPeakData.playerCount)
element.title = `At ${formatTimestampSeconds(ping.graphPeakData.timestamp)}`
})
this._renderValue("peak", (element) => {
element.innerText = formatNumber(ping.graphPeakData.playerCount);
element.title = `At ${formatTimestampSeconds(
ping.graphPeakData.timestamp
)}`;
});
this.lastPeakData = ping.graphPeakData
this.lastPeakData = ping.graphPeakData;
}
if (ping.error) {
this._hideValue('player-count')
this._renderValue('error', ping.error.message)
} else if (typeof ping.playerCount !== 'number') {
this._hideValue('player-count')
this._hideValue("player-count");
this._renderValue("error", ping.error.message);
} else if (typeof ping.playerCount !== "number") {
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
// In this case playerCount will safely be null, so provide a generic error message instead
this._renderValue('error', 'Failed to ping')
} else if (typeof ping.playerCount === 'number') {
this._hideValue('error')
this._renderValue('player-count', formatNumber(ping.playerCount))
this._renderValue("error", "Failed to ping");
} else if (typeof ping.playerCount === "number") {
this._hideValue("error");
this._renderValue("player-count", formatNumber(ping.playerCount));
}
// An updated favicon has been sent, update the src
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
// Otherwise the browser may send multiple requests to the same URL
if (faviconElement.getAttribute('src') !== ping.favicon) {
faviconElement.setAttribute('src', ping.favicon)
if (faviconElement.getAttribute("src") !== ping.favicon) {
faviconElement.setAttribute("src", ping.favicon);
}
}
}
initServerStatus (latestPing) {
const serverElement = document.createElement('div')
initServerStatus(latestPing) {
const serverElement = document.createElement("div");
serverElement.id = `container_${this.serverId}`
serverElement.id = `container_${this.serverId}`;
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>
</div>
<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-label" id="player-count_${this.serverId}">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="player-count_${
this.serverId
}">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>
</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) {
['player-count', 'peak', 'record'].forEach((category) => {
const labelElement = document.getElementById(`${category}_${this.serverId}`)
const valueElement = document.getElementById(`${category}-value_${this.serverId}`)
updateHighlightedValue(selectedCategory) {
["player-count", "peak", "record"].forEach((category) => {
const labelElement = document.getElementById(
`${category}_${this.serverId}`
);
const valueElement = document.getElementById(
`${category}-value_${this.serverId}`
);
if (selectedCategory && category === selectedCategory) {
labelElement.setAttribute('class', 'server-highlighted-label')
valueElement.setAttribute('class', 'server-highlighted-value')
labelElement.setAttribute("class", "server-highlighted-label");
valueElement.setAttribute("class", "server-highlighted-value");
} else {
labelElement.setAttribute('class', 'server-label')
valueElement.setAttribute('class', 'server-value')
labelElement.setAttribute("class", "server-label");
valueElement.setAttribute("class", "server-value");
}
})
});
}
initEventListeners () {
document.getElementById(`favorite-toggle_${this.serverId}`).addEventListener('click', () => {
this._app.favoritesManager.handleFavoriteButtonClick(this)
}, false)
initEventListeners() {
document
.getElementById(`favorite-toggle_${this.serverId}`)
.addEventListener(
"click",
() => {
this._app.favoritesManager.handleFavoriteButtonClick(this);
},
false
);
}
}

@ -1,176 +1,205 @@
export class SocketManager {
constructor (app) {
this._app = app
this._hasRequestedHistoryGraph = false
this._reconnectDelayBase = 0
constructor(app) {
this._app = app;
this._hasRequestedHistoryGraph = false;
this._reconnectDelayBase = 0;
}
reset () {
this._hasRequestedHistoryGraph = false
reset() {
this._hasRequestedHistoryGraph = false;
}
createWebSocket () {
let webSocketProtocol = 'ws:'
if (location.protocol === 'https:') {
webSocketProtocol = 'wss:'
createWebSocket() {
let webSocketProtocol = "ws:";
if (location.protocol === "https:") {
webSocketProtocol = "wss:";
}
this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`)
this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`);
// The backend will automatically push data once connected
this._webSocket.onopen = () => {
this._app.caption.set('Loading...')
this._app.caption.set("Loading...");
// Reset reconnection scheduling since the WebSocket has been established
this._reconnectDelayBase = 0
}
this._reconnectDelayBase = 0;
};
this._webSocket.onclose = (event) => {
this._app.handleDisconnect()
this._app.handleDisconnect();
// Modify page state to display loading overlay
// 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
// Treat other codes as active errors (besides connectivity errors) when displaying the message
if (event.code === 1006) {
this._app.caption.set('Lost connection!')
this._app.caption.set("Lost connection!");
} else {
this._app.caption.set('Disconnected due to error.')
this._app.caption.set("Disconnected due to error.");
}
// Schedule socket reconnection attempt
this.scheduleReconnect()
}
this.scheduleReconnect();
};
this._webSocket.onmessage = (message) => {
const payload = JSON.parse(message.data)
const payload = JSON.parse(message.data);
switch (payload.message) {
case 'init':
this._app.setPublicConfig(payload.config)
case "init":
this._app.setPublicConfig(payload.config);
// Display the main page component
// 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
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
if (this._app.publicConfig.isGraphVisible) {
this.sendHistoryGraphRequest()
this.sendHistoryGraphRequest();
}
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
// Alert the app it is ready
this._app.handleSyncComplete()
this._app.handleSyncComplete();
break
break;
case 'updateServers': {
for (let serverId = 0; serverId < payload.updates.length; serverId++) {
case "updateServers": {
for (
let serverId = 0;
serverId < payload.updates.length;
serverId++
) {
// The backend may send "update" events prior to receiving all "add" events
// A server has only been added once it's ServerRegistration is defined
// Checking undefined protects from this race condition
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
const serverUpdate = payload.updates[serverId]
const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverId);
const serverUpdate = payload.updates[serverId];
if (serverRegistration) {
serverRegistration.handlePing(serverUpdate, payload.timestamp)
serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions)
serverRegistration.handlePing(serverUpdate, payload.timestamp);
serverRegistration.updateServerStatus(
serverUpdate,
this._app.publicConfig.minecraftVersions
);
}
}
// Bulk add playerCounts into graph during #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
this._app.graphDisplayManager.redraw()
this._app.graphDisplayManager.redraw();
}
this._app.percentageBar.redraw()
this._app.updateGlobalStats()
this._app.percentageBar.redraw();
this._app.updateGlobalStats();
break
break;
}
case 'historyGraph': {
this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData)
case "historyGraph": {
this._app.graphDisplayManager.buildPlotInstance(
payload.timestamps,
payload.graphData
);
// Build checkbox elements for graph controls
let lastRowCounter = 0
let controlsHTML = ''
let lastRowCounter = 0;
let controlsHTML = "";
this._app.serverRegistry.getServerRegistrations()
.map(serverRegistration => serverRegistration.data.name)
this._app.serverRegistry
.getServerRegistrations()
.map((serverRegistration) => serverRegistration.data.name)
.sort()
.forEach(serverName => {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName)
.forEach((serverName) => {
const serverRegistration =
this._app.serverRegistry.getServerRegistration(serverName);
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}
</label></td>`
</label></td>`;
// Occasionally break table rows using a magic number
if (++lastRowCounter % 6 === 0) {
controlsHTML += '</tr><tr>'
controlsHTML += "</tr><tr>";
}
})
});
// Apply generated HTML and show controls
document.getElementById('big-graph-checkboxes').innerHTML = `<table><tr>${controlsHTML}</tr></table>`
document.getElementById('big-graph-controls').style.display = 'block'
document.getElementById(
"big-graph-checkboxes"
).innerHTML = `<table><tr>${controlsHTML}</tr></table>`;
document.getElementById("big-graph-controls").style.display = "block";
// Bind click event for updating graph data
this._app.graphDisplayManager.initEventListeners()
break
}
this._app.graphDisplayManager.initEventListeners();
break;
}
}
};
}
scheduleReconnect () {
scheduleReconnect() {
// Release any active WebSocket references
this._webSocket = undefined
this._webSocket = undefined;
this._reconnectDelayBase++
this._reconnectDelayBase++;
// Exponential backoff for reconnection attempts
// 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(() => {
this._reconnectDelaySeconds--
this._reconnectDelaySeconds--;
if (this._reconnectDelaySeconds === 0) {
// Explicitly clear interval, this avoids race conditions
// #clearInterval first to avoid potential errors causing pre-mature returns
clearInterval(reconnectInterval)
clearInterval(reconnectInterval);
// Update displayed text
this._app.caption.set('Reconnecting...')
this._app.caption.set("Reconnecting...");
// Attempt reconnection
// Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started
this.createWebSocket()
this.createWebSocket();
} else if (this._reconnectDelaySeconds > 0) {
// 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) {
this._hasRequestedHistoryGraph = true
this._hasRequestedHistoryGraph = true;
// 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._webSocket.send('requestHistoryGraph')
this._webSocket.send("requestHistoryGraph");
}
}
}

@ -1,199 +1,215 @@
const SORT_OPTIONS = [
{
getName: () => 'Players',
getName: () => "Players",
sortFunc: (a, b) => b.playerCount - a.playerCount,
highlightedValue: 'player-count'
highlightedValue: "player-count",
},
{
getName: (app) => {
return `${app.publicConfig.graphDurationLabel} Peak`
return `${app.publicConfig.graphDurationLabel} Peak`;
},
sortFunc: (a, b) => {
if (!a.lastPeakData && !b.lastPeakData) {
return 0
return 0;
} else if (a.lastPeakData && !b.lastPeakData) {
return -1
return -1;
} 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) => {
// Require at least one ServerRegistration to have a lastPeakData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
if (serverRegistration.lastPeakData) {
return true
return true;
}
}
return false
return false;
},
highlightedValue: 'peak'
highlightedValue: "peak",
},
{
getName: () => 'Record',
getName: () => "Record",
sortFunc: (a, b) => {
if (!a.lastRecordData && !b.lastRecordData) {
return 0
return 0;
} else if (a.lastRecordData && !b.lastRecordData) {
return -1
return -1;
} 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) => {
// Require at least one ServerRegistration to have a lastRecordData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
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_STORAGE_KEY = 'minetrack_sort_option_index'
const SORT_OPTION_INDEX_DEFAULT = 0;
const SORT_OPTION_INDEX_STORAGE_KEY = "minetrack_sort_option_index";
export class SortController {
constructor (app) {
this._app = app
this._buttonElement = document.getElementById('sort-by')
this._textElement = document.getElementById('sort-by-text')
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT
constructor(app) {
this._app = app;
this._buttonElement = document.getElementById("sort-by");
this._textElement = document.getElementById("sort-by-text");
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT;
}
reset () {
this._lastSortedServers = undefined
reset() {
this._lastSortedServers = undefined;
// Reset modified DOM structures
this._buttonElement.style.display = 'none'
this._textElement.innerText = '...'
this._buttonElement.style.display = "none";
this._textElement.innerText = "...";
// Remove bound DOM event listeners
this._buttonElement.removeEventListener('click', this.handleSortByButtonClick)
this._buttonElement.removeEventListener(
"click",
this.handleSortByButtonClick
);
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
const sortOptionIndex = localStorage.getItem(SORT_OPTION_INDEX_STORAGE_KEY)
loadLocalStorage() {
if (typeof localStorage !== "undefined") {
const sortOptionIndex = localStorage.getItem(
SORT_OPTION_INDEX_STORAGE_KEY
);
if (sortOptionIndex) {
this._sortOptionIndex = parseInt(sortOptionIndex)
this._sortOptionIndex = parseInt(sortOptionIndex);
}
}
}
updateLocalStorage () {
if (typeof localStorage !== 'undefined') {
updateLocalStorage() {
if (typeof localStorage !== "undefined") {
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 {
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY)
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY);
}
}
}
show () {
show() {
// Load the saved option selection, if any
this.loadLocalStorage()
this.loadLocalStorage();
this.updateSortOption()
this.updateSortOption();
// Bind DOM event 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
this._buttonElement.style.display = 'inline-block'
this._buttonElement.style.display = "inline-block";
}
handleSortByButtonClick = () => {
while (true) {
// 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
// This can technically cause an infinite loop, but never should assuming
// 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)) {
break
break;
}
}
// Redraw the button and sort the servers
this.updateSortOption()
this.updateSortOption();
// Save the updated option selection
this.updateLocalStorage()
}
this.updateLocalStorage();
};
updateSortOption = () => {
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
const sortOption = SORT_OPTIONS[this._sortOptionIndex];
// 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
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
serverRegistration.updateHighlightedValue(sortOption.highlightedValue)
serverRegistration.updateHighlightedValue(sortOption.highlightedValue);
}
this.sortServers()
}
this.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) {
return -1
return -1;
} 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
// 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) {
let allMatch = true
let allMatch = true;
// Test if the arrays have actually changed
// No need to length check, they are the same source data each time
for (let i = 0; i < sortedServerIds.length; i++) {
if (sortedServerIds[i] !== this._lastSortedServers[i]) {
allMatch = false
break
allMatch = false;
break;
}
}
if (allMatch) {
return
return;
}
}
this._lastSortedServers = sortedServerIds
this._lastSortedServers = sortedServerIds;
// Sort a ServerRegistration list by the sortOption ONLY
// 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
sortedServers.forEach(function (serverRegistration) {
const parentElement = document.getElementById('server-list')
const serverElement = document.getElementById(`container_${serverRegistration.serverId}`)
const parentElement = document.getElementById("server-list");
const serverElement = document.getElementById(
`container_${serverRegistration.serverId}`
);
parentElement.appendChild(serverElement)
parentElement.appendChild(serverElement);
// 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 {
constructor () {
this._div = document.getElementById('tooltip')
constructor() {
this._div = document.getElementById("tooltip");
}
set (x, y, offsetX, offsetY, html) {
this._div.innerHTML = html
set(x, y, offsetX, offsetY, html) {
this._div.innerHTML = html;
// 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
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
// never gets close or surpasses the page's X width
if (x + offsetX + (tooltipWidth * 1.2) > window.innerWidth) {
x -= tooltipWidth
offsetX *= -1
if (x + offsetX + tooltipWidth * 1.2 > window.innerWidth) {
x -= tooltipWidth;
offsetX *= -1;
}
this._div.style.top = `${y + offsetY}px`
this._div.style.left = `${x + offsetX}px`
this._div.style.top = `${y + offsetY}px`;
this._div.style.left = `${x + offsetX}px`;
}
hide = () => {
this._div.style.display = 'none'
}
this._div.style.display = "none";
};
}
export class Caption {
constructor () {
this._div = document.getElementById('status-text')
constructor() {
this._div = document.getElementById("status-text");
}
set (text) {
this._div.innerText = text
this._div.style.display = 'block'
set(text) {
this._div.innerText = text;
this._div.style.display = "block";
}
hide () {
this._div.style.display = 'none'
hide() {
this._div.style.display = "none";
}
}
// Minecraft Java Edition default server port: 25565
// 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)) {
return `${ip}:${port}`
return `${ip}:${port}`;
}
return ip
return ip;
}
// Detect gaps in versions by matching their indexes to knownVersions
export function formatMinecraftVersions (versions, knownVersions) {
if (!versions || !versions.length || !knownVersions || !knownVersions.length) {
return
export function formatMinecraftVersions(versions, knownVersions) {
if (
!versions ||
!versions.length ||
!knownVersions ||
!knownVersions.length
) {
return;
}
let currentVersionGroup = []
const versionGroups = []
let currentVersionGroup = [];
const versionGroups = [];
for (let i = 0; i < versions.length; i++) {
// Look for value mismatch between the previous index
// Require i > 0 since lastVersionIndex is undefined for i === 0
if (i > 0 && versions[i] - versions[i - 1] !== 1) {
versionGroups.push(currentVersionGroup)
currentVersionGroup = []
versionGroups.push(currentVersionGroup);
currentVersionGroup = [];
}
currentVersionGroup.push(versions[i])
currentVersionGroup.push(versions[i]);
}
// Ensure the last versionGroup is always pushed
if (currentVersionGroup.length > 0) {
versionGroups.push(currentVersionGroup)
versionGroups.push(currentVersionGroup);
}
if (versionGroups.length === 0) {
return
return;
}
// Remap individual versionGroups values into named versions
return versionGroups.map(versionGroup => {
const startVersion = knownVersions[versionGroup[0]]
return versionGroups
.map((versionGroup) => {
const startVersion = knownVersions[versionGroup[0]];
if (versionGroup.length === 1) {
// 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
return startVersion
return startVersion;
} else {
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]]
return `${startVersion}-${endVersion}`
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]];
return `${startVersion}-${endVersion}`;
}
}).join(', ')
})
.join(", ");
}
export function formatTimestampSeconds (secs) {
const date = new Date(0)
date.setUTCSeconds(secs)
return date.toLocaleTimeString()
export function formatTimestampSeconds(secs) {
const date = new Date(0);
date.setUTCSeconds(secs);
return date.toLocaleTimeString();
}
export function formatDate (secs) {
const date = new Date(0)
date.setUTCSeconds(secs)
return date.toLocaleDateString()
export function formatDate(secs) {
const date = new Date(0);
date.setUTCSeconds(secs);
return date.toLocaleDateString();
}
export function formatPercent (x, over) {
const val = Math.round((x / over) * 100 * 10) / 10
return `${val}%`
export function formatPercent(x, over) {
const val = Math.round((x / over) * 100 * 10) / 10;
return `${val}%`;
}
export function formatNumber (x) {
if (typeof x !== 'number') {
return '-'
export function formatNumber(x) {
if (typeof x !== "number") {
return "-";
} 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 PingController = require('./ping')
const Server = require('./server')
const { TimeTracker } = require('./time')
const MessageOf = require('./message')
const Database = require("./database");
const PingController = require("./ping");
const Server = require("./server");
const { TimeTracker } = require("./time");
const MessageOf = require("./message");
const config = require('../config')
const minecraftVersions = require('../minecraft_versions')
const config = require("../config");
const minecraftVersions = require("../minecraft_versions");
class App {
serverRegistrations = []
serverRegistrations = [];
constructor () {
this.pingController = new PingController(this)
this.server = new Server(this)
this.timeTracker = new TimeTracker(this)
constructor() {
this.pingController = new PingController(this);
this.server = new Server(this);
this.timeTracker = new TimeTracker(this);
}
loadDatabase (callback) {
this.database = new Database(this)
loadDatabase(callback) {
this.database = new Database(this);
// Setup database instance
this.database.ensureIndexes(() => {
this.database.loadGraphPoints(config.graphDuration, () => {
this.database.loadRecords(() => {
if (config.oldPingsCleanup && config.oldPingsCleanup.enabled) {
this.database.initOldPingsDelete(callback)
this.database.initOldPingsDelete(callback);
} else {
callback()
callback();
}
})
})
})
});
});
});
}
handleReady () {
this.server.listen(config.site.ip, config.site.port)
handleReady() {
this.server.listen(config.site.ip, config.site.port);
// Allow individual modules to manage their own task scheduling
this.pingController.schedule()
this.pingController.schedule();
}
handleClientConnection = (client) => {
if (config.logToDatabase) {
client.on('message', (message) => {
if (message === 'requestHistoryGraph') {
client.on("message", (message) => {
if (message === "requestHistoryGraph") {
// 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
// any header data being appended by #MessageOf since the graph data is fed
// directly into the graphing system
client.send(MessageOf('historyGraph', {
client.send(
MessageOf("historyGraph", {
timestamps: this.timeTracker.getGraphPoints(),
graphData
}))
}
graphData,
})
);
}
});
}
const initMessage = {
config: (() => {
// Remap minecraftVersion entries into name values
const minecraftVersionNames = {}
const minecraftVersionNames = {};
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
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(),
serverGraphMaxLength: TimeTracker.getMaxServerGraphDataLength(),
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPublicData()),
servers: this.serverRegistrations.map((serverRegistration) =>
serverRegistration.getPublicData()
),
minecraftVersions: minecraftVersionNames,
isGraphVisible: config.logToDatabase
}
isGraphVisible: config.logToDatabase,
};
})(),
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 { TimeTracker } = require('./time')
const dataFolder = 'data/';
const config = require("../config");
const { TimeTracker } = require("./time");
const dataFolder = "data/";
class Database {
constructor (app) {
this._app = app
this._sql = new sqlite.Database(dataFolder + 'database.sql')
constructor(app) {
this._app = app;
this._sql = new sqlite.Database(dataFolder + "database.sql");
}
getDailyDatabase () {
getDailyDatabase() {
if (!config.createDailyDatabaseCopy) {
return
return;
}
const date = new Date()
const fileName = `database_copy_${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}.sql`
const date = new Date();
const fileName = `database_copy_${date.getDate()}-${
date.getMonth() + 1
}-${date.getFullYear()}.sql`;
if (fileName !== this._currentDatabaseCopyFileName) {
if (this._currentDatabaseCopyInstance) {
this._currentDatabaseCopyInstance.close()
this._currentDatabaseCopyInstance.close();
}
this._currentDatabaseCopyInstance = new sqlite.Database(dataFolder + fileName)
this._currentDatabaseCopyFileName = fileName
this._currentDatabaseCopyInstance = new sqlite.Database(
dataFolder + fileName
);
this._currentDatabaseCopyFileName = fileName;
// Ensure the initial tables are created
// This does not created indexes since it is only inserted to
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) {
logger.log('error', 'Cannot create initial table for daily database')
throw err
logger.log(
"error",
"Cannot create initial table for daily database"
);
throw err;
}
})
})
}
);
});
}
return this._currentDatabaseCopyInstance
return this._currentDatabaseCopyInstance;
}
ensureIndexes (callback) {
const handleError = err => {
ensureIndexes(callback) {
const handleError = (err) => {
if (err) {
logger.log('error', 'Cannot create table or table index')
throw err
}
logger.log("error", "Cannot create table or table index");
throw err;
}
};
this._sql.serialize(() => {
this._sql.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)', handleError)
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)
this._sql.run(
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
handleError
);
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.
// Note that queries not scheduled directly in the callback function of
// #serialize are not necessarily serialized.
callback()
})
})
callback();
}
);
});
}
loadGraphPoints (graphDuration, callback) {
loadGraphPoints(graphDuration, callback) {
// Query recent pings
const endTime = TimeTracker.getEpochMillis()
const startTime = endTime - graphDuration
const endTime = TimeTracker.getEpochMillis();
const startTime = endTime - graphDuration;
this.getRecentPings(startTime, endTime, pingData => {
const relativeGraphData = []
this.getRecentPings(startTime, endTime, (pingData) => {
const relativeGraphData = [];
for (const row of pingData) {
// Load into temporary array
// This will be culled prior to being pushed to the serverRegistration
let graphData = relativeGraphData[row.ip]
let graphData = relativeGraphData[row.ip];
if (!graphData) {
relativeGraphData[row.ip] = graphData = [[], []]
relativeGraphData[row.ip] = graphData = [[], []];
}
// DANGER!
// 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 enables all timestamp arrays to have consistent point selection and graph correctly
graphData[0].push(row.timestamp)
graphData[1].push(row.playerCount)
graphData[0].push(row.timestamp);
graphData[1].push(row.playerCount);
}
Object.keys(relativeGraphData).forEach(ip => {
Object.keys(relativeGraphData).forEach((ip) => {
// Match IPs to serverRegistration object
for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.data.ip === ip) {
const graphData = relativeGraphData[ip]
const graphData = relativeGraphData[ip];
// 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
// This is very dangerous and can break if data is out of sync
if (Object.keys(relativeGraphData).length > 0) {
const serverIp = Object.keys(relativeGraphData)[0]
const timestamps = relativeGraphData[serverIp][0]
const serverIp = Object.keys(relativeGraphData)[0];
const timestamps = relativeGraphData[serverIp][0];
this._app.timeTracker.loadGraphPoints(startTime, timestamps)
this._app.timeTracker.loadGraphPoints(startTime, timestamps);
}
callback()
})
callback();
});
}
loadRecords (callback) {
let completedTasks = 0
loadRecords(callback) {
let completedTasks = 0;
this._app.serverRegistrations.forEach(serverRegistration => {
this._app.serverRegistrations.forEach((serverRegistration) => {
// Find graphPeaks
// This pre-computes the values prior to clients connecting
serverRegistration.findNewGraphPeak()
serverRegistration.findNewGraphPeak();
// Query recordData
// 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) {
serverRegistration.recordData = {
playerCount,
timestamp: TimeTracker.toSeconds(timestamp)
}
timestamp: TimeTracker.toSeconds(timestamp),
};
} 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
let newTimestamp = null
let newPlayerCount = null
let newTimestamp = null;
let newPlayerCount = null;
// If legacy record found, use it for insertion
if (hasRecordLegacy) {
newTimestamp = timestampLegacy
newPlayerCount = playerCountLegacy
newTimestamp = timestampLegacy;
newPlayerCount = playerCountLegacy;
}
// Set record to recordData
serverRegistration.recordData = {
playerCount: newPlayerCount,
timestamp: TimeTracker.toSeconds(newTimestamp)
}
timestamp: TimeTracker.toSeconds(newTimestamp),
};
// Insert server entry to records table
const statement = this._sql.prepare('INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)')
statement.run(newTimestamp, serverRegistration.data.ip, newPlayerCount, err => {
const statement = this._sql.prepare(
"INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(
newTimestamp,
serverRegistration.data.ip,
newPlayerCount,
(err) => {
if (err) {
logger.error(`Cannot insert initial player count record of ${serverRegistration.data.ip}`)
throw err
logger.error(
`Cannot insert initial player count record of ${serverRegistration.data.ip}`
);
throw err;
}
})
statement.finalize()
})
}
);
statement.finalize();
}
);
}
// Check if completedTasks hit the finish value
// Fire callback since #readyDatabase is complete
if (++completedTasks === this._app.serverRegistrations.length) {
callback()
callback();
}
})
})
}
);
});
}
getRecentPings (startTime, endTime, callback) {
this._sql.all('SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?', [
startTime,
endTime
], (err, data) => {
getRecentPings(startTime, endTime, callback) {
this._sql.all(
"SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?",
[startTime, endTime],
(err, data) => {
if (err) {
logger.log('error', 'Cannot get recent pings')
throw err
logger.log("error", "Cannot get recent pings");
throw err;
}
callback(data)
})
callback(data);
}
);
}
getRecord (ip, callback) {
this._sql.all('SELECT playerCount, timestamp FROM players_record WHERE ip = ?', [
ip
], (err, data) => {
getRecord(ip, callback) {
this._sql.all(
"SELECT playerCount, timestamp FROM players_record WHERE ip = ?",
[ip],
(err, data) => {
if (err) {
logger.log('error', `Cannot get ping record for ${ip}`)
throw err
logger.log("error", `Cannot get ping record for ${ip}`);
throw err;
}
// Record not found
if (data[0] === undefined) {
// eslint-disable-next-line node/no-callback-literal
callback(false)
return
callback(false);
return;
}
const playerCount = data[0].playerCount
const timestamp = data[0].timestamp
const playerCount = data[0].playerCount;
const timestamp = data[0].timestamp;
// Allow null player counts and timestamps, the frontend will safely handle them
// 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
getRecordLegacy (ip, callback) {
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [
ip
], (err, data) => {
getRecordLegacy(ip, callback) {
this._sql.all(
"SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?",
[ip],
(err, data) => {
if (err) {
logger.log('error', `Cannot get legacy ping record for ${ip}`)
throw err
logger.log("error", `Cannot get legacy ping record for ${ip}`);
throw err;
}
// For empty results, data will be length 1 with [null, null]
const playerCount = data[0]['MAX(playerCount)']
const timestamp = data[0].timestamp
const playerCount = data[0]["MAX(playerCount)"];
const timestamp = data[0].timestamp;
// Allow null timestamps, the frontend will safely handle them
// This allows insertion of free standing records without a known timestamp
if (playerCount !== null) {
// eslint-disable-next-line node/no-callback-literal
callback(true, playerCount, timestamp)
callback(true, playerCount, timestamp);
} else {
// eslint-disable-next-line node/no-callback-literal
callback(false)
callback(false);
}
})
}
);
}
insertPing (ip, timestamp, unsafePlayerCount) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql)
insertPing(ip, timestamp, unsafePlayerCount) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql);
// Push a copy of the data into the database copy, if any
// This creates an "insert only" copy of the database for archiving
const dailyDatabase = this.getDailyDatabase()
const dailyDatabase = this.getDailyDatabase();
if (dailyDatabase) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase)
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase);
}
}
_insertPingTo (ip, timestamp, unsafePlayerCount, db) {
const statement = db.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)')
statement.run(timestamp, ip, unsafePlayerCount, err => {
_insertPingTo(ip, timestamp, unsafePlayerCount, db) {
const statement = db.prepare(
"INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(timestamp, ip, unsafePlayerCount, (err) => {
if (err) {
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`)
throw err
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`);
throw err;
}
})
statement.finalize()
});
statement.finalize();
}
updatePlayerCountRecord (ip, playerCount, timestamp) {
const statement = this._sql.prepare('UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?')
statement.run(timestamp, playerCount, ip, err => {
updatePlayerCountRecord(ip, playerCount, timestamp) {
const statement = this._sql.prepare(
"UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?"
);
statement.run(timestamp, playerCount, ip, (err) => {
if (err) {
logger.error(`Cannot update player count record of ${ip} at ${timestamp}`)
throw err
logger.error(
`Cannot update player count record of ${ip} at ${timestamp}`
);
throw err;
}
})
statement.finalize()
});
statement.finalize();
}
initOldPingsDelete (callback) {
initOldPingsDelete(callback) {
// Delete old pings on startup
logger.info('Deleting old pings..')
logger.info("Deleting old pings..");
this.deleteOldPings(() => {
const oldPingsCleanupInterval = config.oldPingsCleanup.interval || 3600000
const oldPingsCleanupInterval =
config.oldPingsCleanup.interval || 3600000;
if (oldPingsCleanupInterval > 0) {
// 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
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration;
const deleteStart = TimeTracker.getEpochMillis()
const statement = this._sql.prepare('DELETE FROM pings WHERE timestamp < ?;')
statement.run(oldestTimestamp, err => {
const deleteStart = TimeTracker.getEpochMillis();
const statement = this._sql.prepare(
"DELETE FROM pings WHERE timestamp < ?;"
);
statement.run(oldestTimestamp, (err) => {
if (err) {
logger.error('Cannot delete old pings')
throw err
logger.error("Cannot delete old pings");
throw err;
} else {
const deleteTook = TimeTracker.getEpochMillis() - deleteStart
logger.info(`Old pings deleted in ${deleteTook}ms`)
const deleteTook = TimeTracker.getEpochMillis() - deleteStart;
logger.info(`Old pings deleted in ${deleteTook}ms`);
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 {
constructor (ip, port) {
this._ip = ip
this._port = port
constructor(ip, port) {
this._ip = ip;
this._port = port;
}
_skipSrv () {
this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT
_skipSrv() {
this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT;
}
_isSkipSrv () {
return this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil
_isSkipSrv() {
return (
this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil
);
}
resolve (callback) {
resolve(callback) {
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) => {
if (!callbackFired) {
callbackFired = true
callbackFired = true;
// 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
if (!callbackFired) {
clearTimeout(timeoutCallback)
clearTimeout(timeoutCallback);
}
// 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
// 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
// 0 represents a disabled flag
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 {
// 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, {
filename: 'minetrack.log'
})
filename: "minetrack.log",
});
winston.add(winston.transports.Console, {
timestamp: () => {
const date = new Date()
return date.toLocaleTimeString() + ' ' + date.toLocaleDateString()
const date = new Date();
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({
message: name,
...data
})
}
...data,
});
};

@ -1,159 +1,212 @@
const minecraftJavaPing = require('mcping-js')
const minecraftBedrockPing = require('mcpe-ping-fixed')
const minecraftJavaPing = require("mcping-js");
const minecraftBedrockPing = require("mcpe-ping-fixed");
const logger = require('./logger')
const MessageOf = require('./message')
const { TimeTracker } = require('./time')
const logger = require("./logger");
const MessageOf = require("./message");
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) {
case 'PC':
case "PC":
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) => {
if (err) {
callback(err)
callback(err);
} else {
const payload = {
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
if (res.favicon && res.favicon.startsWith('data:image/')) {
payload.favicon = res.favicon
if (res.favicon && res.favicon.startsWith("data:image/")) {
payload.favicon = res.favicon;
}
callback(null, payload)
callback(null, payload);
}
})
})
break
});
});
break;
case 'PE':
minecraftBedrockPing(serverRegistration.data.ip, serverRegistration.data.port || 19132, (err, res) => {
case "PE":
minecraftBedrockPing(
serverRegistration.data.ip,
serverRegistration.data.port || 19132,
(err, res) => {
if (err) {
callback(err)
callback(err);
} else {
callback(null, {
players: {
online: capPlayerCount(serverRegistration.data.ip, parseInt(res.currentPlayers))
online: capPlayerCount(
serverRegistration.data.ip,
parseInt(res.currentPlayers)
),
},
});
}
})
}
}, timeout)
break
},
timeout
);
break;
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
// Artificially cap and warn to prevent propogating garbage
function capPlayerCount (host, playerCount) {
const maxPlayerCount = 250000
function capPlayerCount(host, playerCount) {
const maxPlayerCount = 250000;
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)) {
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 {
constructor (app) {
this._app = app
this._isRunningTasks = false
constructor(app) {
this._app = app;
this._isRunningTasks = false;
}
schedule () {
setInterval(this.pingAll, config.rates.pingAll)
schedule() {
setInterval(this.pingAll, config.rates.pingAll);
this.pingAll()
this.pingAll();
}
pingAll = () => {
const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp()
const { timestamp, updateHistoryGraph } =
this._app.timeTracker.newPointTimestamp();
this.startPingTasks(results => {
const updates = []
this.startPingTasks((results) => {
const updates = [];
for (const serverRegistration of this._app.serverRegistrations) {
const result = results[serverRegistration.serverId]
const result = results[serverRegistration.serverId];
// Log to database if enabled
// Use null to represent a failed ping
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
// This includes any modified fields and flags used by the frontend
// 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 a single timestamp entry since it is shared
this._app.server.broadcast(MessageOf('updateServers', {
this._app.server.broadcast(
MessageOf("updateServers", {
timestamp: TimeTracker.toSeconds(timestamp),
updateHistoryGraph,
updates
}))
updates,
})
}
);
});
};
startPingTasks = (callback) => {
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) {
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) {
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] = {
resp,
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
this._isRunningTasks = false
this._isRunningTasks = false;
callback(results)
}
}, version.protocolId)
callback(results);
}
},
version.protocolId
);
}
};
}
module.exports = PingController
module.exports = PingController;

@ -1,114 +1,139 @@
const http = require('http')
const format = require('util').format
const http = require("http");
const format = require("util").format;
const WebSocket = require('ws')
const finalHttpHandler = require('finalhandler')
const serveStatic = require('serve-static')
const WebSocket = require("ws");
const finalHttpHandler = require("finalhandler");
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) {
return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress
function getRemoteAddr(req) {
return (
req.headers["cf-connecting-ip"] ||
req.headers["x-forwarded-for"] ||
req.connection.remoteAddress
);
}
class Server {
static getHashedFaviconUrl (hash) {
static getHashedFaviconUrl(hash) {
// Format must be compatible with HASHED_FAVICON_URL_REGEX
return format('/hashedfavicon_%s.png', hash)
return format("/hashedfavicon_%s.png", hash);
}
constructor (app) {
this._app = app
constructor(app) {
this._app = app;
this.createHttpServer()
this.createWebSocketServer()
this.createHttpServer();
this.createWebSocketServer();
}
createHttpServer () {
const distServeStatic = serveStatic('dist/')
const faviconsServeStatic = serveStatic('favicons/')
createHttpServer() {
const distServeStatic = serveStatic("dist/");
const faviconsServeStatic = serveStatic("favicons/");
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
// Require only 1 match ([0]) and test its first captured group ([1])
// 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])) {
return
if (
faviconHash.length === 1 &&
this.handleFaviconRequest(res, faviconHash[0][1])
) {
return;
}
// Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic
// If faviconServeStatic fails, pass to finalHttpHandler to terminate
distServeStatic(req, res, () => {
faviconsServeStatic(req, res, finalHttpHandler(req, res))
})
})
faviconsServeStatic(req, res, finalHttpHandler(req, res));
});
});
}
handleFaviconRequest = (res, faviconHash) => {
for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.faviconHash && serverRegistration.faviconHash === faviconHash) {
const buf = Buffer.from(serverRegistration.lastFavicon.split(',')[1], 'base64')
if (
serverRegistration.faviconHash &&
serverRegistration.faviconHash === faviconHash
) {
const buf = Buffer.from(
serverRegistration.lastFavicon.split(",")[1],
"base64"
);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': buf.length,
'Cache-Control': 'public, max-age=604800' // Cache hashed favicon for 7 days
}).end(buf)
return true
}
}
return false
}
createWebSocketServer () {
this._wss = new WebSocket.Server({
server: this._http
res
.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": buf.length,
"Cache-Control": "public, max-age=604800", // Cache hashed favicon for 7 days
})
.end(buf);
this._wss.on('connection', (client, req) => {
logger.log('info', '%s connected, total clients: %d', getRemoteAddr(req), this.getConnectedClients())
return true;
}
}
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
client.on('close', () => {
logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(req), this.getConnectedClients())
})
client.on("close", () => {
logger.log(
"info",
"%s disconnected, total clients: %d",
getRemoteAddr(req),
this.getConnectedClients()
);
});
// Pass client off to proxy handler
this._app.handleClientConnection(client)
})
this._app.handleClientConnection(client);
});
}
listen (host, port) {
this._http.listen(port, host)
listen(host, port) {
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) {
this._wss.clients.forEach(client => {
broadcast(payload) {
this._wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload)
client.send(payload);
}
})
});
}
getConnectedClients () {
let count = 0
this._wss.clients.forEach(client => {
getConnectedClients() {
let count = 0;
this._wss.clients.forEach((client) => {
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 Server = require('./server')
const DNSResolver = require("./dns");
const Server = require("./server");
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time')
const { getPlayerCountOrNull } = require('./util')
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require("./time");
const { getPlayerCountOrNull } = require("./util");
const config = require('../config')
const minecraftVersions = require('../minecraft_versions')
const config = require("../config");
const minecraftVersions = require("../minecraft_versions");
class ServerRegistration {
serverId
lastFavicon
versions = []
recordData
graphData = []
serverId;
lastFavicon;
versions = [];
recordData;
graphData = [];
constructor (app, serverId, data) {
this._app = app
this.serverId = serverId
this.data = data
this._pingHistory = []
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port)
constructor(app, serverId, data) {
this._app = app;
this.serverId = serverId;
this.data = data;
this._pingHistory = [];
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
const unsafePlayerCount = getPlayerCountOrNull(resp)
const unsafePlayerCount = getPlayerCountOrNull(resp);
// 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
// if both the graphing behavior is enabled and the backend agrees
// that the ping is eligible for addition
if (updateHistoryGraph) {
TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength())
TimeTracker.pushAndShift(
this.graphData,
unsafePlayerCount,
TimeTracker.getMaxGraphDataLength()
);
}
// Delegate out update payload generation
return this.getUpdate(timestamp, resp, err, version)
return this.getUpdate(timestamp, resp, err, version);
}
getUpdate (timestamp, resp, err, version) {
const update = {}
getUpdate(timestamp, resp, err, version) {
const update = {};
// Always append a playerCount value
// When resp is undefined (due to an error), playerCount will be null
update.playerCount = getPlayerCountOrNull(resp)
update.playerCount = getPlayerCountOrNull(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
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 = {
playerCount: resp.players.online,
timestamp: TimeTracker.toSeconds(timestamp)
}
timestamp: TimeTracker.toSeconds(timestamp),
};
// Append an updated recordData
update.recordData = this.recordData
update.recordData = this.recordData;
// 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)) {
update.favicon = this.getFaviconUrl()
update.favicon = this.getFaviconUrl();
}
if (config.logToDatabase) {
// 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
if (this.findNewGraphPeak()) {
update.graphPeakData = this.getGraphPeak()
update.graphPeakData = this.getGraphPeak();
}
}
} else if (err) {
// Append a filtered copy of err
// 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) {
const payload = {
versions: this.versions,
recordData: this.recordData,
favicon: this.getFaviconUrl()
}
favicon: this.getFaviconUrl(),
};
// Only append graphPeakData if defined
// The value is lazy computed and conditional that config->logToDatabase == true
const graphPeakData = this.getGraphPeak()
const graphPeakData = this.getGraphPeak();
if (graphPeakData) {
payload.graphPeakData = graphPeakData
payload.graphPeakData = graphPeakData;
}
// 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
// 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
// Include the last value even though it is contained within payload
// The frontend will only push to its graphData from playerCountHistory
payload.playerCountHistory = this._pingHistory
payload.playerCountHistory = this._pingHistory;
return payload
return payload;
}
return {
error: {
message: 'Pinging...'
message: "Pinging...",
},
recordData: this.recordData,
graphPeakData: this.getGraphPeak(),
favicon: this.data.favicon
}
favicon: this.data.favicon,
};
}
loadGraphPoints (startTime, timestamps, points) {
this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i])
loadGraphPoints(startTime, timestamps, points) {
this.graphData = TimeTracker.everyN(
timestamps,
startTime,
GRAPH_UPDATE_TIME_GAP,
(i) => points[i]
);
}
findNewGraphPeak () {
let index = -1
findNewGraphPeak() {
let index = -1;
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])) {
index = i
index = i;
}
}
if (index >= 0) {
const lastGraphPeakIndex = this._graphPeakIndex
this._graphPeakIndex = index
return index !== lastGraphPeakIndex
const lastGraphPeakIndex = this._graphPeakIndex;
this._graphPeakIndex = index;
return index !== lastGraphPeakIndex;
} else {
this._graphPeakIndex = undefined
return false
this._graphPeakIndex = undefined;
return false;
}
}
getGraphPeak () {
getGraphPeak() {
if (this._graphPeakIndex === undefined) {
return
return;
}
return {
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
// Disregard the incoming favicon, regardless if it is different
if (this.data.favicon) {
return false
return false;
}
if (favicon && favicon !== this.lastFavicon) {
this.lastFavicon = favicon
this.lastFavicon = favicon;
// Generate an updated hash
// 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) {
return Server.getHashedFaviconUrl(this.faviconHash)
return Server.getHashedFaviconUrl(this.faviconHash);
} 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
const isSuccess = incomingId === outgoingId
const indexOf = this.versions.indexOf(protocolIndex)
const isSuccess = incomingId === outgoingId;
const indexOf = this.versions.indexOf(protocolIndex);
// Test indexOf to avoid inserting previously recorded protocolIndex values
if (isSuccess && indexOf < 0) {
this.versions.push(protocolIndex)
this.versions.push(protocolIndex);
// Sort versions in ascending 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) {
this.versions.splice(indexOf, 1)
return true
this.versions.splice(indexOf, 1);
return true;
}
return false
return false;
}
getNextProtocolVersion () {
getNextProtocolVersion() {
// Minecraft Bedrock Edition does not have protocol versions
if (this.data.type === 'PE') {
if (this.data.type === "PE") {
return {
protocolId: 0,
protocolIndex: 0
protocolIndex: 0,
};
}
}
const protocolVersions = minecraftVersions[this.data.type]
if (typeof this._nextProtocolIndex === 'undefined' || this._nextProtocolIndex + 1 >= protocolVersions.length) {
this._nextProtocolIndex = 0
const protocolVersions = minecraftVersions[this.data.type];
if (
typeof this._nextProtocolIndex === "undefined" ||
this._nextProtocolIndex + 1 >= protocolVersions.length
) {
this._nextProtocolIndex = 0;
} else {
this._nextProtocolIndex++
this._nextProtocolIndex++;
}
return {
protocolId: protocolVersions[this._nextProtocolIndex].protocolId,
protocolIndex: this._nextProtocolIndex
}
protocolIndex: this._nextProtocolIndex,
};
}
filterError (err) {
let message = 'Unknown error'
filterError(err) {
let message = "Unknown error";
// 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]) {
message = err[key]
break
message = err[key];
break;
}
}
// Trim the message if too long
if (message.length > 28) {
message = message.substring(0, 28) + '...'
message = message.substring(0, 28) + "...";
}
return {
message: message
}
message: message,
};
}
getPublicData () {
getPublicData() {
// Return a custom object instead of data directly to avoid data leakage
return {
name: this.data.name,
ip: this.data.ip,
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 {
constructor (app) {
this._app = app
this._serverGraphPoints = []
this._graphPoints = []
constructor(app) {
this._app = app;
this._serverGraphPoints = [];
this._graphPoints = [];
}
newPointTimestamp () {
const timestamp = TimeTracker.getEpochMillis()
newPointTimestamp() {
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
// 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) {
this._lastHistoryGraphUpdate = timestamp
this._lastHistoryGraphUpdate = timestamp;
// Push into timestamps array to update backend state
TimeTracker.pushAndShift(this._graphPoints, timestamp, TimeTracker.getMaxGraphDataLength())
TimeTracker.pushAndShift(
this._graphPoints,
timestamp,
TimeTracker.getMaxGraphDataLength()
);
}
return {
timestamp,
updateHistoryGraph
}
updateHistoryGraph,
};
}
loadGraphPoints (startTime, timestamps) {
loadGraphPoints(startTime, timestamps) {
// This is a copy of ServerRegistration#loadGraphPoints
// 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) {
return TimeTracker.toSeconds(this._graphPoints[i])
getGraphPointAt(i) {
return TimeTracker.toSeconds(this._graphPoints[i]);
}
getServerGraphPoints () {
return this._serverGraphPoints.map(TimeTracker.toSeconds)
getServerGraphPoints() {
return this._serverGraphPoints.map(TimeTracker.toSeconds);
}
getGraphPoints () {
return this._graphPoints.map(TimeTracker.toSeconds)
getGraphPoints() {
return this._graphPoints.map(TimeTracker.toSeconds);
}
static toSeconds = (timestamp) => {
return Math.floor(timestamp / 1000)
return Math.floor(timestamp / 1000);
};
static getEpochMillis() {
return new Date().getTime();
}
static getEpochMillis () {
return new Date().getTime()
static getMaxServerGraphDataLength() {
return Math.ceil(config.serverGraphDuration / config.rates.pingAll);
}
static getMaxServerGraphDataLength () {
return Math.ceil(config.serverGraphDuration / config.rates.pingAll)
static getMaxGraphDataLength() {
return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP);
}
static getMaxGraphDataLength () {
return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP)
}
static everyN (array, start, diff, adapter) {
const selected = []
let lastPoint = start
static everyN(array, start, diff, adapter) {
const selected = [];
let lastPoint = start;
for (let i = 0; i < array.length; i++) {
const point = array[i]
const point = array[i];
if (point - lastPoint >= diff) {
lastPoint = point
selected.push(adapter(i))
lastPoint = point;
selected.push(adapter(i));
}
}
return selected
return selected;
}
static pushAndShift (array, value, maxLength) {
array.push(value)
static pushAndShift(array, value, maxLength) {
array.push(value);
if (array.length > maxLength) {
array.splice(0, array.length - maxLength)
array.splice(0, array.length - maxLength);
}
}
}
module.exports = {
GRAPH_UPDATE_TIME_GAP,
TimeTracker
}
TimeTracker,
};

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