Compare commits

..

No commits in common. "main" and "Cryptkeeper-main" have entirely different histories.

34 changed files with 1932 additions and 2290 deletions

@ -1,3 +1,5 @@
{ {
"plugins": ["@babel/plugin-proposal-class-properties"] "plugins": [
"@babel/plugin-proposal-class-properties"
]
} }

@ -1,3 +1,19 @@
{ {
"parser": "babel-eslint" "env": {
"browser": true,
"es6": true
},
"extends": [
"standard"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
},
"parser": "babel-eslint"
} }

@ -1,43 +0,0 @@
name: Publish Docker Image
on:
push:
branches:
- "main"
jobs:
docker:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Restore Docker Cache
uses: actions/cache@v3
id: docker-cache
with:
path: /usr/bin/docker
key: ${{ runner.os }}-docker
- name: Install Docker (if not cached)
if: steps.docker-cache.outputs.cache-hit != 'true'
run: |
wget -q -O /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-20.10.23.tgz \
&& tar --extract --file /tmp/docker.tgz --directory /usr/bin --strip-components 1 --no-same-owner docker/docker \
&& rm -rf /tmp/* &&
echo "Done"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Repo
uses: docker/login-action@v2
with:
username: ${{ secrets.REPO_USERNAME }}
password: ${{ secrets.REPO_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v4
with:
push: true
tags: fascinated/minetrack:latest

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

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

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

@ -1,101 +1,73 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head>
<title>Minetrack</title>
<meta charset="UTF-8" />
<!-- Discord Embed --> <head>
<meta content="Minetrack" property="og:title" />
<meta
content="Tracking minecraft servers since 2023"
property="og:description"
/>
<meta content="https://mc.fascinated.cc/" property="og:url" />
<link rel="stylesheet" href="../css/main.css" /> <link rel="stylesheet" href="../css/main.css">
<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" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;700&display=swap">
<script defer type="module" src="../js/main.js"></script> <link rel="icon" type="image/svg+xml" href="../images/logo.svg">
</head>
<body> <meta charset="UTF-8">
<div id="tooltip"></div>
<div id="status-overlay"> <script defer type="module" src="../js/main.js"></script>
<img class="logo-image" src="../images/logo.svg" />
<h1 class="logo-text">Minetrack</h1>
<div id="status-text">Connecting...</div>
</div>
<div id="push"> <title>Minetrack</title>
<div id="perc-bar"></div>
<header> </head>
<div class="header-possible-row-break column-left">
<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>
</div>
<div class="header-possible-row-break column-right"> <body>
<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 <div id="tooltip"></div>
id="settings-toggle"
class="header-button header-button-single"
style="margin-left: 20px"
>
<span class="icon-gears"></span> Graph Controls
</div>
</div>
</header>
<div id="big-graph"></div> <div id="status-overlay">
<img class="logo-image" src="../images/logo.svg">
<h1 class="logo-text">Minetrack</h1>
<div id="status-text">Connecting...</div>
</div>
<div id="big-graph-controls"> <div id="push">
<div id="big-graph-controls-drawer">
<div id="big-graph-checkboxes"></div>
<span class="graph-controls-setall"> <div id="perc-bar"></div>
<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> <header>
</div> <div class="header-possible-row-break column-left">
<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>
</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="settings-toggle" class="header-button header-button-single" style="margin-left: 20px;"><span class="icon-gears"></span> Graph Controls</div>
</div>
</header>
<div id="big-graph"></div>
<div id="big-graph-controls">
<div id="big-graph-controls-drawer">
<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>
</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>
</footer>
</body>
<footer id="footer">
<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>
</html> </html>

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

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

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

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

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

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

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

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

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

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

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

@ -1,18 +1,18 @@
{ {
"site": { "site": {
"port": 8080, "port": 8080,
"ip": "0.0.0.0" "ip": "0.0.0.0"
}, },
"rates": { "rates": {
"pingAll": 30000, "pingAll": 15000,
"connectTimeout": 5000 "connectTimeout": 2500
}, },
"oldPingsCleanup": { "oldPingsCleanup": {
"enabled": false, "enabled": false,
"interval": 3600000 "interval": 3600000
}, },
"logFailedPings": true, "logFailedPings": true,
"logToDatabase": true, "logToDatabase": true,
"graphDuration": 604800000, "graphDuration": 86400000,
"serverGraphDuration": 360000 "serverGraphDuration": 180000
} }

@ -1,20 +1,12 @@
version: "3" version: '3'
services: services:
minetrack: minetrack:
image: fascinated/minetrack:latest build: .
# or
# build: https://git.fascinated.cc/Fascinated/Minetrack.git
container_name: minetrack container_name: minetrack
dns: dns:
- 8.8.8.8 - 8.8.8.8
- 1.1.1.1 - 1.1.1.1
ports: ports:
- "8880:8080" - "80:8080"
volumes:
# Copy these from the git repo
- ./servers.json:/usr/src/minetrack/servers.json
- ./config.json:/usr/src/minetrack/config.json
- ./data:/usr/src/minetrack/data # The sqlite database will be stored here
restart: always restart: always

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

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

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

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

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

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

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

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

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

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

@ -1,28 +0,0 @@
/**
* Formats a time in milliseconds to a human readable format
* eg: 1000ms -> 1s or 60000ms -> 1m
*
* @param ms the time in milliseconds
* @returns the formatted time
*/
function formatMsToTime(ms) {
// this is really fucking shitty but it works!
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d`;
} else if (hours > 0) {
return `${hours}h`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return `${seconds}s`;
}
}
module.exports = {
formatMsToTime,
};

@ -1,160 +1,160 @@
{ {
"PC": [ "PC": [
{ {
"name": "1.7.2", "name": "1.7.2",
"protocolId": 4 "protocolId": 4
}, },
{ {
"name": "1.7.10", "name": "1.7.10",
"protocolId": 5 "protocolId": 5
}, },
{ {
"name": "1.8", "name": "1.8",
"protocolId": 47 "protocolId": 47
}, },
{ {
"name": "1.9", "name": "1.9",
"protocolId": 107 "protocolId": 107
}, },
{ {
"name": "1.9.1", "name": "1.9.1",
"protocolId": 108 "protocolId": 108
}, },
{ {
"name": "1.9.2", "name": "1.9.2",
"protocolId": 109 "protocolId": 109
}, },
{ {
"name": "1.9.4", "name": "1.9.4",
"protocolId": 110 "protocolId": 110
}, },
{ {
"name": "1.10", "name": "1.10",
"protocolId": 210 "protocolId": 210
}, },
{ {
"name": "1.11", "name": "1.11",
"protocolId": 315 "protocolId": 315
}, },
{ {
"name": "1.11.2", "name": "1.11.2",
"protocolId": 316 "protocolId": 316
}, },
{ {
"name": "1.12", "name": "1.12",
"protocolId": 335 "protocolId": 335
}, },
{ {
"name": "1.12.1", "name": "1.12.1",
"protocolId": 338 "protocolId": 338
}, },
{ {
"name": "1.12.2", "name": "1.12.2",
"protocolId": 340 "protocolId": 340
}, },
{ {
"name": "1.13", "name": "1.13",
"protocolId": 393 "protocolId": 393
}, },
{ {
"name": "1.13.1", "name": "1.13.1",
"protocolId": 401 "protocolId": 401
}, },
{ {
"name": "1.13.2", "name": "1.13.2",
"protocolId": 404 "protocolId": 404
}, },
{ {
"name": "1.14", "name": "1.14",
"protocolId": 477 "protocolId": 477
}, },
{ {
"name": "1.14.1", "name": "1.14.1",
"protocolId": 480 "protocolId": 480
}, },
{ {
"name": "1.14.2", "name": "1.14.2",
"protocolId": 485 "protocolId": 485
}, },
{ {
"name": "1.14.3", "name": "1.14.3",
"protocolId": 490 "protocolId": 490
}, },
{ {
"name": "1.14.4", "name": "1.14.4",
"protocolId": 498 "protocolId": 498
}, },
{ {
"name": "1.15", "name": "1.15",
"protocolId": 573 "protocolId": 573
}, },
{ {
"name": "1.15.1", "name": "1.15.1",
"protocolId": 575 "protocolId": 575
}, },
{ {
"name": "1.15.2", "name": "1.15.2",
"protocolId": 578 "protocolId": 578
}, },
{ {
"name": "1.16", "name": "1.16",
"protocolId": 735 "protocolId": 735
}, },
{ {
"name": "1.16.1", "name": "1.16.1",
"protocolId": 736 "protocolId": 736
}, },
{ {
"name": "1.16.2", "name": "1.16.2",
"protocolId": 751 "protocolId": 751
}, },
{ {
"name": "1.16.3", "name": "1.16.3",
"protocolId": 753 "protocolId": 753
}, },
{ {
"name": "1.16.5", "name": "1.16.5",
"protocolId": 754 "protocolId": 754
}, },
{ {
"name": "1.17", "name": "1.17",
"protocolId": 755 "protocolId": 755
}, },
{ {
"name": "1.18.1", "name": "1.18.1",
"protocolId": 757 "protocolId": 757
}, },
{ {
"name": "1.18.2", "name": "1.18.2",
"protocolId": 758 "protocolId": 758
}, },
{ {
"name": "1.19", "name": "1.19",
"protocolId": 759 "protocolId": 759
}, },
{ {
"name": "1.19.2", "name": "1.19.2",
"protocolId": 760 "protocolId": 760
}, },
{ {
"name": "1.19.3", "name": "1.19.3",
"protocolId": 761 "protocolId": 761
}, },
{ {
"name": "1.19.4", "name": "1.19.4",
"protocolId": 762 "protocolId": 762
}, },
{ {
"name": "1.20.1", "name": "1.20.1",
"protocolId": 763 "protocolId": 763
}, },
{ {
"name": "1.20.2", "name": "1.20.2",
"protocolId": 764 "protocolId": 764
}, },
{ {
"name": "1.20.4", "name": "1.20.4",
"protocolId": 765 "protocolId": 765
} }
] ]
} }

@ -1,5 +1,5 @@
while true; while true;
do do
node main.js node main.js
sleep 5 sleep 5
done done

@ -1,7 +1,207 @@
[ [
{ {
"name": "WildPrison", "name": "Dracarys",
"ip": "wildprison.net", "ip": "dracarys.pro",
"type": "PC" "type": "PC"
} },
{
"name": "FearGames",
"ip": "mc.feargames.it",
"type": "PC"
},
{
"name": "RubyCraft",
"ip": "mc.rubycraft.it",
"type": "PC"
},
{
"name": "MinecraftExperience",
"ip": "minecraftexperience.it",
"type": "PC"
},
{
"name": "FusionWorld",
"ip": "mc.fusionworld.it",
"type": "PC"
},
{
"name": "MetaMC",
"ip": "metamc.it",
"type": "PC"
},
{
"name": "HeriaMC",
"ip": "play.heriamc.it",
"type": "PC"
},
{
"name": "Kibelius",
"ip": "kibelius.com",
"type": "PC"
},
{
"name": "OverLands",
"ip": "mc.overlands.online",
"type": "PC"
},
{
"name": "Titanet",
"ip": "mc.titanet.it",
"type": "PC"
},
{
"name": "HaleaNetwork",
"ip": "play.haleanetwork.net",
"type": "PC"
},
{
"name": "2ManyMines",
"ip": "mc.2manymines.it",
"type": "PC"
},
{
"name": "PeterNetwork",
"ip": "peternetwork.it",
"type": "PC"
},
{
"name": "NovaCraft",
"ip": "mc.novacraft.it",
"type": "PC"
},
{
"name": "CoralMC",
"ip": "play.coralmc.it",
"type": "PC"
},
{
"name": "ViperNetwork",
"ip": "mc.vipernetwork.it",
"type": "PC"
},
{
"name": "ElytariaClub",
"ip": "mc.elytaria.club",
"type": "PC"
},
{
"name": "RgbCraft",
"ip": "mc.rgbcraft.com",
"type": "PC"
},
{
"name": "Age of Feuds",
"ip": "mc.ageoffeuds.it",
"type": "PC"
},
{
"name": "CrabMC",
"ip": "play.crabmc.it",
"type": "PC"
},
{
"name": "AtlasMC",
"ip": "play.atlasmc.it",
"type": "PC"
},
{
"name": "OceanWay",
"ip": "mc.oceanway.it",
"type": "PC"
},
{
"name": "zKraft",
"ip": "mc.zkraft.net",
"type": "PC"
},
{
"name": "RebornAge",
"ip": "mc.Reborn-Age.it",
"type": "PC"
},
{
"name": "SottoSopravvivenza",
"ip": "mc.sottosopravvivenza.it",
"type": "PC"
},
{
"name": "EasyMC",
"ip": "easymc.it",
"type": "PC"
},
{
"name": "MyVanilla",
"ip": "myvanilla.my.to",
"type": "PC"
},
{
"name": "Regno di Zeal",
"ip": "mc.regnodizeal.it",
"type": "PC"
},
{
"name": "MintMC",
"ip": "play.mintmc.it",
"type": "PC"
},
{
"name": "HydraMC",
"ip": "play.hydramc.it",
"type": "PC"
},
{
"name": "Palladiums",
"ip": "mc.palladiums.it",
"type": "PC"
},
{
"name": "StaryMC",
"ip": "mc.starymc.it",
"type": "PC"
},
{
"name": "DragonCraft",
"ip": "mc.dragoncraft.it",
"type": "PC"
},
{
"name": "BuinCraft",
"ip": "mc.buincraft.it",
"type": "PC"
},
{
"name": "RankuenMC",
"ip": "mc.rankuenmc.com",
"type": "PC"
},
{
"name": "PrymaCommunity",
"ip": "mc.prymacommunity.it",
"type": "PC"
},
{
"name": "DomusMC",
"ip": "mc.domusitalia.net",
"type": "PC"
},
{
"name": "AtlantisRP",
"ip": "play.atlantisrp.it",
"type": "PC"
},
{
"name": "WaveMC",
"ip": "play.icenetwork.it",
"type": "PE"
},
{
"name": "IceNetwork",
"ip": "play.icenetwork.it",
"type": "PC"
},
{
"name": "EverCraft",
"ip": "mc.evercraft.it",
"type": "PC"
}
] ]