Compare commits
31 Commits
Cryptkeepe
...
main
Author | SHA1 | Date | |
---|---|---|---|
41c28168e9 | |||
565b533ad4 | |||
5c5e44ad20 | |||
f9a9a37e1a | |||
0fbea0cb4e | |||
2992ad84da | |||
f9c7c90d44 | |||
cc0c0f2368 | |||
1d4dd08fc6 | |||
0d03516d04 | |||
4bce544d98 | |||
ea15b979d5 | |||
6fd5fdb7fe | |||
1663a127cc | |||
f295167766 | |||
0949c75b07 | |||
|
785285a7ac | ||
|
9b7426eb43 | ||
|
1a183cc85d | ||
|
e58cad1adc | ||
|
e5211d0a36 | ||
|
5b40dacaf8 | ||
|
37c1f9c7c1 | ||
|
cc20220568 | ||
|
b649c25e11 | ||
|
1494f6c6fd | ||
|
41a3671d2f | ||
|
2fdf3d36d1 | ||
|
7599732abc | ||
|
5955cce3ed | ||
|
10b1db5f72 |
4
.babelrc
4
.babelrc
@ -1,5 +1,3 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
}
|
||||
|
@ -1,19 +1,3 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"standard"
|
||||
],
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018
|
||||
},
|
||||
"rules": {
|
||||
},
|
||||
"parser": "babel-eslint"
|
||||
}
|
||||
|
43
.gitea/workflows/publish.yml
Normal file
43
.gitea/workflows/publish.yml
Normal file
@ -0,0 +1,43 @@
|
||||
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
|
51
README.md
51
README.md
@ -3,12 +3,15 @@
|
||||
</p>
|
||||
|
||||
# Minetrack
|
||||
Minetrack makes it easy to keep an eye on your favorite Minecraft servers. Simple and hackable, Minetrack easily runs on any hardware. Use it for monitoring, analytics, or just for fun. [Check it out](https://minetrack.me).
|
||||
|
||||
### This project is not actively supported!
|
||||
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. Pull requests will be reviewed and merged (if accepted), but issues _might_ not be addressed outside of fixes provided by community members. Please share any improvements or fixes you've made so everyone can benefit from them.
|
||||
|
||||
### Features
|
||||
|
||||
- 🚀 Real time Minecraft server player count tracking with customizable update speed.
|
||||
- 📝 Historical player count logging with 24 hour peak and player count record tracking.
|
||||
- 📈 Historical graph with customizable time frame.
|
||||
@ -17,42 +20,38 @@ This project is not actively supported. Pull requests will be reviewed and merge
|
||||
- 🕹 Supports both Minecraft Java Edition and Minecraft Bedrock Edition.
|
||||
|
||||
### Community Showcase
|
||||
|
||||
You can find a list of community hosted instances below. Want to be listed here? Add yourself in a pull request!
|
||||
|
||||
* https://minetrack.me
|
||||
* https://bedrock.minetrack.me
|
||||
* https://suomimine.fi
|
||||
* https://minetrack.geyserconnect.net
|
||||
* https://minetrack.rmly.dev
|
||||
* https://minetrack.fi
|
||||
* https://pvp-factions.fr
|
||||
* https://stats.liste-serveurs.fr
|
||||
* https://minetrack.galaxite.dev
|
||||
* https://livemc.org
|
||||
- <https://mc.fascinated.cc/>
|
||||
|
||||
## Updates
|
||||
|
||||
For updates and release notes, please read the [CHANGELOG](docs/CHANGELOG.md).
|
||||
|
||||
**Migrating to Minetrack 5?** See the [migration guide](docs/MIGRATING.md).
|
||||
|
||||
## Installation
|
||||
|
||||
1. Node 12.4.0+ is required (you can check your version using `node -v`)
|
||||
2. Make sure everything is correct in ```config.json```.
|
||||
3. Add/remove servers by editing the ```servers.json``` file
|
||||
4. Run ```npm install```
|
||||
5. Run ```npm run build``` (this bundles `assets/` into `dist/`)
|
||||
6. Run ```node main.js``` to boot the system (may need sudo!)
|
||||
2. Make sure everything is correct in `config.json`.
|
||||
3. Add/remove servers by editing the `servers.json` file
|
||||
4. Run `npm install`
|
||||
5. Run `npm run build` (this bundles `assets/` into `dist/`)
|
||||
6. Run `node main.js` to boot the system (may need sudo!)
|
||||
|
||||
(There's also ```install.sh``` and ```start.sh```, but they may not work for your OS.)
|
||||
(There's also `install.sh` and `start.sh`, but they may not work for your OS.)
|
||||
|
||||
Database logging is disabled by default. You can enable it in ```config.json``` by setting ```logToDatabase``` to true.
|
||||
Database logging is disabled by default. You can enable it in `config.json` by setting `logToDatabase` to true.
|
||||
This requires sqlite3 drivers to be installed.
|
||||
|
||||
## Docker
|
||||
|
||||
Minetrack can be built and run with Docker from this repository in several ways:
|
||||
|
||||
### Build and deploy directly with Docker
|
||||
```
|
||||
|
||||
```bash
|
||||
# build image with name minetrack and tag latest
|
||||
docker build . --tag minetrack:latest
|
||||
|
||||
@ -62,11 +61,13 @@ docker run --rm --publish 80:8080 minetrack:latest
|
||||
```
|
||||
|
||||
The published port can be changed by modifying the parameter argument, e.g.:
|
||||
* Publish to host port 8080: `--publish 8080:8080`
|
||||
* Publish to localhost (thus prohibiting external access): `--publish 127.0.0.1:8080:8080`
|
||||
|
||||
- Publish to host port 8080: `--publish 8080:8080`
|
||||
- Publish to localhost (thus prohibiting external access): `--publish 127.0.0.1:8080:8080`
|
||||
|
||||
### Build and deploy with docker-compose
|
||||
```
|
||||
|
||||
```bash
|
||||
# build and start service
|
||||
docker-compose up --build
|
||||
|
||||
@ -75,8 +76,10 @@ docker-compose down
|
||||
```
|
||||
|
||||
## Nginx reverse proxy
|
||||
|
||||
The following configuration enables Nginx to act as reverse proxy for a Minetrack instance that is available at port 8080 on localhost:
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name minetrack.example.net;
|
||||
listen 80;
|
||||
|
@ -1,17 +1,17 @@
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src:
|
||||
url('../fonts/icomoon.ttf?gn52nv') format('truetype'),
|
||||
url('../fonts/icomoon.woff?gn52nv') format('woff'),
|
||||
url('../fonts/icomoon.svg?gn52nv#icomoon') format('svg');
|
||||
font-family: "icomoon";
|
||||
src: url("../fonts/icomoon.ttf?gn52nv") format("truetype"),
|
||||
url("../fonts/icomoon.woff?gn52nv") format("woff"),
|
||||
url("../fonts/icomoon.svg?gn52nv#icomoon") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"], [class*=" icon-"] {
|
||||
[class^="icon-"],
|
||||
[class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'icomoon' !important;
|
||||
font-family: "icomoon" !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
|
@ -8,15 +8,15 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-dark-gray: #A3A3A3;
|
||||
--color-gold: #FFD700;
|
||||
--color-dark-gray: #a3a3a3;
|
||||
--color-gold: #ffd700;
|
||||
--color-dark-purple: #6c5ce7;
|
||||
--color-light-purple: #a29bfe;
|
||||
--color-dark-blue: #0984e3;
|
||||
--color-light-blue: #74b9ff;
|
||||
|
||||
--theme-color-dark: #3B3738;
|
||||
--theme-color-light: #EBEBEB;
|
||||
--theme-color-dark: #3b3738;
|
||||
--theme-color-light: #ebebeb;
|
||||
|
||||
--border-radius: 1px;
|
||||
|
||||
@ -27,12 +27,12 @@
|
||||
--background-color: var(--theme-color-light);
|
||||
--text-decoration-color: var(--theme-color-dark);
|
||||
--text-color: #000;
|
||||
--text-color-inverted: #FFF;
|
||||
--text-color-inverted: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #212021;
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@ -42,13 +42,13 @@ body {
|
||||
--color-blue-inverted: var(--color-light-blue);
|
||||
--background-color: var(--theme-color-dark);
|
||||
--text-decoration-color: var(--theme-color-light);
|
||||
--text-color: #FFF;
|
||||
--text-color: #fff;
|
||||
--text-color-inverted: #000;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1c1b1c;
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,8 @@ body {
|
||||
}
|
||||
|
||||
/* Page layout */
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@ -316,7 +317,9 @@ footer a:hover {
|
||||
padding-right: 65px;
|
||||
}
|
||||
|
||||
#big-graph, #big-graph-controls, #big-graph-checkboxes {
|
||||
#big-graph,
|
||||
#big-graph-controls,
|
||||
#big-graph-checkboxes {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
|
@ -1,48 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Minetrack</title>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<head>
|
||||
<!-- Discord Embed -->
|
||||
<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="stylesheet" href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;700&display=swap">
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="../images/logo.svg">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="../images/logo.svg" />
|
||||
|
||||
<script defer type="module" src="../js/main.js"></script>
|
||||
</head>
|
||||
|
||||
<title>Minetrack</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<body>
|
||||
<div id="tooltip"></div>
|
||||
|
||||
<div id="status-overlay">
|
||||
<img class="logo-image" src="../images/logo.svg">
|
||||
<img class="logo-image" src="../images/logo.svg" />
|
||||
<h1 class="logo-text">Minetrack</h1>
|
||||
<div id="status-text">Connecting...</div>
|
||||
</div>
|
||||
|
||||
<div id="push">
|
||||
|
||||
<div id="perc-bar"></div>
|
||||
|
||||
<header>
|
||||
<div class="header-possible-row-break column-left">
|
||||
<img class="logo-image" src="../images/logo.svg">
|
||||
<img class="logo-image" src="../images/logo.svg" />
|
||||
<h1 class="logo-text">Minetrack</h1>
|
||||
<p class="logo-status">Counting <span class="global-stat" id="stat_totalPlayers">0</span> players on <span class="global-stat" id="stat_networks">0</span> Minecraft servers.</p>
|
||||
<p class="logo-status">
|
||||
Counting
|
||||
<span class="global-stat" id="stat_totalPlayers">0</span> players on
|
||||
<span class="global-stat" id="stat_networks">0</span> Minecraft
|
||||
servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="header-possible-row-break column-right">
|
||||
<div id="sort-by" class="header-button header-button-single"><span class="icon-sort-amount-desc"></span> Sort By<br><strong id="sort-by-text">...</strong></div>
|
||||
<div id="sort-by" class="header-button header-button-single">
|
||||
<span class="icon-sort-amount-desc"></span> Sort By<br /><strong
|
||||
id="sort-by-text"
|
||||
>...</strong
|
||||
>
|
||||
</div>
|
||||
|
||||
<div id="settings-toggle" class="header-button header-button-single" style="margin-left: 20px;"><span class="icon-gears"></span> Graph Controls</div>
|
||||
<div
|
||||
id="settings-toggle"
|
||||
class="header-button header-button-single"
|
||||
style="margin-left: 20px"
|
||||
>
|
||||
<span class="icon-gears"></span> Graph Controls
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -53,21 +73,29 @@
|
||||
<div id="big-graph-checkboxes"></div>
|
||||
|
||||
<span class="graph-controls-setall">
|
||||
<a minetrack-show-type="all" class="button graph-controls-show"><span class="icon-eye"></span> Show All</a>
|
||||
<a minetrack-show-type="none" class="button graph-controls-show"><span class="icon-eye-slash"></span> Hide All</a>
|
||||
<a minetrack-show-type="favorites" class="button graph-controls-show"><span class="icon-star"></span> Only Favorites</a>
|
||||
<a minetrack-show-type="all" class="button graph-controls-show"
|
||||
><span class="icon-eye"></span> Show All</a
|
||||
>
|
||||
<a minetrack-show-type="none" class="button graph-controls-show"
|
||||
><span class="icon-eye-slash"></span> Hide All</a
|
||||
>
|
||||
<a
|
||||
minetrack-show-type="favorites"
|
||||
class="button graph-controls-show"
|
||||
><span class="icon-star"></span> Only Favorites</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="server-list"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer id="footer">
|
||||
<span class="icon-code"></span> Powered by open source software - <a href="https://github.com/Cryptkeeper/Minetrack">make it your own!</a>
|
||||
<span class="icon-code"></span> Powered by open source software -
|
||||
<a href="https://git.fascinated.cc/Fascinated/Minetrack"
|
||||
>make it your own!</a
|
||||
>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
157
assets/js/app.js
157
assets/js/app.js
@ -1,100 +1,105 @@
|
||||
import { ServerRegistry } from './servers'
|
||||
import { SocketManager } from './socket'
|
||||
import { SortController } from './sort'
|
||||
import { GraphDisplayManager } from './graph'
|
||||
import { PercentageBar } from './percbar'
|
||||
import { FavoritesManager } from './favorites'
|
||||
import { Tooltip, Caption, formatNumber } from './util'
|
||||
import { FavoritesManager } from "./favorites";
|
||||
import { GraphDisplayManager } from "./graph";
|
||||
import { PercentageBar } from "./percbar";
|
||||
import { ServerRegistry } from "./servers";
|
||||
import { SocketManager } from "./socket";
|
||||
import { SortController } from "./sort";
|
||||
import { Caption, Tooltip, formatNumber } from "./util";
|
||||
|
||||
export class App {
|
||||
publicConfig
|
||||
publicConfig;
|
||||
|
||||
constructor () {
|
||||
this.tooltip = new Tooltip()
|
||||
this.caption = new Caption()
|
||||
this.serverRegistry = new ServerRegistry(this)
|
||||
this.socketManager = new SocketManager(this)
|
||||
this.sortController = new SortController(this)
|
||||
this.graphDisplayManager = new GraphDisplayManager(this)
|
||||
this.percentageBar = new PercentageBar(this)
|
||||
this.favoritesManager = new FavoritesManager(this)
|
||||
constructor() {
|
||||
this.tooltip = new Tooltip();
|
||||
this.caption = new Caption();
|
||||
this.serverRegistry = new ServerRegistry(this);
|
||||
this.socketManager = new SocketManager(this);
|
||||
this.sortController = new SortController(this);
|
||||
this.graphDisplayManager = new GraphDisplayManager(this);
|
||||
this.percentageBar = new PercentageBar(this);
|
||||
this.favoritesManager = new FavoritesManager(this);
|
||||
|
||||
this._taskIds = []
|
||||
this._taskIds = [];
|
||||
}
|
||||
|
||||
// Called once the DOM is ready and the app can begin setup
|
||||
init () {
|
||||
this.socketManager.createWebSocket()
|
||||
init() {
|
||||
this.socketManager.createWebSocket();
|
||||
}
|
||||
|
||||
setPageReady (isReady) {
|
||||
document.getElementById('push').style.display = isReady ? 'block' : 'none'
|
||||
document.getElementById('footer').style.display = isReady ? 'block' : 'none'
|
||||
document.getElementById('status-overlay').style.display = isReady ? 'none' : 'block'
|
||||
setPageReady(isReady) {
|
||||
document.getElementById("push").style.display = isReady ? "block" : "none";
|
||||
document.getElementById("footer").style.display = isReady
|
||||
? "block"
|
||||
: "none";
|
||||
document.getElementById("status-overlay").style.display = isReady
|
||||
? "none"
|
||||
: "block";
|
||||
}
|
||||
|
||||
setPublicConfig (publicConfig) {
|
||||
this.publicConfig = publicConfig
|
||||
setPublicConfig(publicConfig) {
|
||||
this.publicConfig = publicConfig;
|
||||
|
||||
this.serverRegistry.assignServers(publicConfig.servers)
|
||||
this.serverRegistry.assignServers(publicConfig.servers);
|
||||
|
||||
// Start repeating frontend tasks once it has received enough data to be considered active
|
||||
// This simplifies management logic at the cost of each task needing to safely handle empty data
|
||||
this.initTasks()
|
||||
this.initTasks();
|
||||
}
|
||||
|
||||
handleSyncComplete () {
|
||||
this.caption.hide()
|
||||
handleSyncComplete() {
|
||||
this.caption.hide();
|
||||
|
||||
// Load favorites since all servers are registered
|
||||
this.favoritesManager.loadLocalStorage()
|
||||
this.favoritesManager.loadLocalStorage();
|
||||
|
||||
// Run a single bulk server sort instead of per-add event since there may be multiple
|
||||
this.sortController.show()
|
||||
this.percentageBar.redraw()
|
||||
this.sortController.show();
|
||||
this.percentageBar.redraw();
|
||||
|
||||
// The data may not be there to correctly compute values, but run an attempt
|
||||
// Otherwise they will be updated by #initTasks
|
||||
this.updateGlobalStats()
|
||||
this.updateGlobalStats();
|
||||
}
|
||||
|
||||
initTasks () {
|
||||
this._taskIds.push(setInterval(this.sortController.sortServers, 5000))
|
||||
initTasks() {
|
||||
this._taskIds.push(setInterval(this.sortController.sortServers, 5000));
|
||||
}
|
||||
|
||||
handleDisconnect () {
|
||||
this.tooltip.hide()
|
||||
handleDisconnect() {
|
||||
this.tooltip.hide();
|
||||
|
||||
// Reset individual tracker elements to flush any held data
|
||||
this.serverRegistry.reset()
|
||||
this.socketManager.reset()
|
||||
this.sortController.reset()
|
||||
this.graphDisplayManager.reset()
|
||||
this.percentageBar.reset()
|
||||
this.serverRegistry.reset();
|
||||
this.socketManager.reset();
|
||||
this.sortController.reset();
|
||||
this.graphDisplayManager.reset();
|
||||
this.percentageBar.reset();
|
||||
|
||||
// Undefine publicConfig, resynced during the connection handshake
|
||||
this.publicConfig = undefined
|
||||
this.publicConfig = undefined;
|
||||
|
||||
// Clear all task ids, if any
|
||||
this._taskIds.forEach(clearInterval)
|
||||
this._taskIds.forEach(clearInterval);
|
||||
|
||||
this._taskIds = []
|
||||
this._taskIds = [];
|
||||
|
||||
// Reset hidden values created by #updateGlobalStats
|
||||
this._lastTotalPlayerCount = undefined
|
||||
this._lastServerRegistrationCount = undefined
|
||||
this._lastTotalPlayerCount = undefined;
|
||||
this._lastServerRegistrationCount = undefined;
|
||||
|
||||
// Reset modified DOM structures
|
||||
document.getElementById('stat_totalPlayers').innerText = 0
|
||||
document.getElementById('stat_networks').innerText = 0
|
||||
document.getElementById("stat_totalPlayers").innerText = 0;
|
||||
document.getElementById("stat_networks").innerText = 0;
|
||||
|
||||
this.setPageReady(false)
|
||||
this.setPageReady(false);
|
||||
}
|
||||
|
||||
getTotalPlayerCount () {
|
||||
return this.serverRegistry.getServerRegistrations()
|
||||
.map(serverRegistration => serverRegistration.playerCount)
|
||||
.reduce((sum, current) => sum + current, 0)
|
||||
getTotalPlayerCount() {
|
||||
return this.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.map((serverRegistration) => serverRegistration.playerCount)
|
||||
.reduce((sum, current) => sum + current, 0);
|
||||
}
|
||||
|
||||
addServer = (serverId, payload, timestampPoints) => {
|
||||
@ -102,51 +107,61 @@ export class App {
|
||||
// result = undefined
|
||||
// error = defined with "Waiting" description
|
||||
// info = safely defined with configured data
|
||||
const serverRegistration = this.serverRegistry.createServerRegistration(serverId)
|
||||
const serverRegistration =
|
||||
this.serverRegistry.createServerRegistration(serverId);
|
||||
|
||||
serverRegistration.initServerStatus(payload)
|
||||
serverRegistration.initServerStatus(payload);
|
||||
|
||||
// playerCountHistory is only defined when the backend has previous ping data
|
||||
// undefined playerCountHistory means this is a placeholder ping generated by the backend
|
||||
if (typeof payload.playerCountHistory !== 'undefined') {
|
||||
if (typeof payload.playerCountHistory !== "undefined") {
|
||||
// Push the historical data into the graph
|
||||
// This will trim and format the data so it is ready for the graph to render once init
|
||||
serverRegistration.addGraphPoints(payload.playerCountHistory, timestampPoints)
|
||||
serverRegistration.addGraphPoints(
|
||||
payload.playerCountHistory,
|
||||
timestampPoints
|
||||
);
|
||||
|
||||
// Set initial playerCount to the payload's value
|
||||
// This will always exist since it is explicitly generated by the backend
|
||||
// This is used for any post-add rendering of things like the percentageBar
|
||||
serverRegistration.playerCount = payload.playerCount
|
||||
serverRegistration.playerCount = payload.playerCount;
|
||||
}
|
||||
|
||||
// Create the plot instance internally with the restructured and cleaned data
|
||||
serverRegistration.buildPlotInstance()
|
||||
serverRegistration.buildPlotInstance();
|
||||
|
||||
// Handle the last known state (if any) as an incoming update
|
||||
// This triggers the main update pipeline and enables centralized update handling
|
||||
serverRegistration.updateServerStatus(payload, this.publicConfig.minecraftVersions)
|
||||
serverRegistration.updateServerStatus(
|
||||
payload,
|
||||
this.publicConfig.minecraftVersions
|
||||
);
|
||||
|
||||
// Allow the ServerRegistration to bind any DOM events with app instance context
|
||||
serverRegistration.initEventListeners()
|
||||
}
|
||||
serverRegistration.initEventListeners();
|
||||
};
|
||||
|
||||
updateGlobalStats = () => {
|
||||
// Only redraw when needed
|
||||
// These operations are relatively cheap, but the site already does too much rendering
|
||||
const totalPlayerCount = this.getTotalPlayerCount()
|
||||
const totalPlayerCount = this.getTotalPlayerCount();
|
||||
|
||||
if (totalPlayerCount !== this._lastTotalPlayerCount) {
|
||||
this._lastTotalPlayerCount = totalPlayerCount
|
||||
document.getElementById('stat_totalPlayers').innerText = formatNumber(totalPlayerCount)
|
||||
this._lastTotalPlayerCount = totalPlayerCount;
|
||||
document.getElementById("stat_totalPlayers").innerText =
|
||||
formatNumber(totalPlayerCount);
|
||||
}
|
||||
|
||||
// Only redraw when needed
|
||||
// These operations are relatively cheap, but the site already does too much rendering
|
||||
const serverRegistrationCount = this.serverRegistry.getServerRegistrations().length
|
||||
const serverRegistrationCount =
|
||||
this.serverRegistry.getServerRegistrations().length;
|
||||
|
||||
if (serverRegistrationCount !== this._lastServerRegistrationCount) {
|
||||
this._lastServerRegistrationCount = serverRegistrationCount
|
||||
document.getElementById('stat_networks').innerText = serverRegistrationCount
|
||||
}
|
||||
this._lastServerRegistrationCount = serverRegistrationCount;
|
||||
document.getElementById("stat_networks").innerText =
|
||||
serverRegistrationCount;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,69 +1,83 @@
|
||||
export const FAVORITE_SERVERS_STORAGE_KEY = 'minetrack_favorite_servers'
|
||||
export const FAVORITE_SERVERS_STORAGE_KEY = "minetrack_favorite_servers";
|
||||
|
||||
export class FavoritesManager {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
}
|
||||
|
||||
loadLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
|
||||
loadLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
|
||||
if (serverNames) {
|
||||
serverNames = JSON.parse(serverNames)
|
||||
serverNames = JSON.parse(serverNames);
|
||||
|
||||
for (let i = 0; i < serverNames.length; i++) {
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverNames[i])
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverNames[i]);
|
||||
|
||||
// The serverName may not exist in the backend configuration anymore
|
||||
// Ensure serverRegistration is defined before mutating data or considering valid
|
||||
if (serverRegistration) {
|
||||
serverRegistration.isFavorite = true
|
||||
serverRegistration.isFavorite = true;
|
||||
|
||||
// Update icon since by default it is unfavorited
|
||||
document.getElementById(`favorite-toggle_${serverRegistration.serverId}`).setAttribute('class', this.getIconClass(serverRegistration.isFavorite))
|
||||
document
|
||||
.getElementById(`favorite-toggle_${serverRegistration.serverId}`)
|
||||
.setAttribute(
|
||||
"class",
|
||||
this.getIconClass(serverRegistration.isFavorite)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
updateLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
// Mutate the serverIds array into server names for storage use
|
||||
const serverNames = this._app.serverRegistry.getServerRegistrations()
|
||||
.filter(serverRegistration => serverRegistration.isFavorite)
|
||||
.map(serverRegistration => serverRegistration.data.name)
|
||||
const serverNames = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.filter((serverRegistration) => serverRegistration.isFavorite)
|
||||
.map((serverRegistration) => serverRegistration.data.name);
|
||||
|
||||
if (serverNames.length > 0) {
|
||||
// Only save if the array contains data, otherwise clear the item
|
||||
localStorage.setItem(FAVORITE_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
|
||||
localStorage.setItem(
|
||||
FAVORITE_SERVERS_STORAGE_KEY,
|
||||
JSON.stringify(serverNames)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY)
|
||||
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleFavoriteButtonClick = (serverRegistration) => {
|
||||
serverRegistration.isFavorite = !serverRegistration.isFavorite
|
||||
serverRegistration.isFavorite = !serverRegistration.isFavorite;
|
||||
|
||||
// Update the displayed favorite icon
|
||||
document.getElementById(`favorite-toggle_${serverRegistration.serverId}`).setAttribute('class', this.getIconClass(serverRegistration.isFavorite))
|
||||
document
|
||||
.getElementById(`favorite-toggle_${serverRegistration.serverId}`)
|
||||
.setAttribute("class", this.getIconClass(serverRegistration.isFavorite));
|
||||
|
||||
// Request the app controller instantly re-sort the server listing
|
||||
// This handles the favorite sorting logic internally
|
||||
this._app.sortController.sortServers()
|
||||
this._app.sortController.sortServers();
|
||||
|
||||
this._app.graphDisplayManager.handleServerIsFavoriteUpdate(serverRegistration)
|
||||
this._app.graphDisplayManager.handleServerIsFavoriteUpdate(
|
||||
serverRegistration
|
||||
);
|
||||
|
||||
// Write an updated settings payload
|
||||
this.updateLocalStorage()
|
||||
}
|
||||
this.updateLocalStorage();
|
||||
};
|
||||
|
||||
getIconClass (isFavorite) {
|
||||
getIconClass(isFavorite) {
|
||||
if (isFavorite) {
|
||||
return 'icon-star server-is-favorite'
|
||||
return "icon-star server-is-favorite";
|
||||
} else {
|
||||
return 'icon-star-o server-is-not-favorite'
|
||||
return "icon-star-o server-is-not-favorite";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,80 +1,87 @@
|
||||
import uPlot from 'uplot'
|
||||
import uPlot from "uplot";
|
||||
|
||||
import { RelativeScale } from './scale'
|
||||
import { RelativeScale } from "./scale";
|
||||
|
||||
import { formatNumber, formatTimestampSeconds } from './util'
|
||||
import { uPlotTooltipPlugin } from './plugins'
|
||||
import { uPlotTooltipPlugin } from "./plugins";
|
||||
import { formatNumber, formatTimestampSeconds } from "./util";
|
||||
|
||||
import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites'
|
||||
import { FAVORITE_SERVERS_STORAGE_KEY } from "./favorites";
|
||||
|
||||
const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers'
|
||||
const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites'
|
||||
const HIDDEN_SERVERS_STORAGE_KEY = "minetrack_hidden_servers";
|
||||
const SHOW_FAVORITES_STORAGE_KEY = "minetrack_show_favorites";
|
||||
|
||||
export class GraphDisplayManager {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._graphData = []
|
||||
this._graphTimestamps = []
|
||||
this._hasLoadedSettings = false
|
||||
this._initEventListenersOnce = false
|
||||
this._showOnlyFavorites = false
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._graphData = [];
|
||||
this._graphTimestamps = [];
|
||||
this._hasLoadedSettings = false;
|
||||
this._initEventListenersOnce = false;
|
||||
this._showOnlyFavorites = false;
|
||||
}
|
||||
|
||||
addGraphPoint (timestamp, playerCounts) {
|
||||
addGraphPoint(timestamp, playerCounts) {
|
||||
if (!this._hasLoadedSettings) {
|
||||
// _hasLoadedSettings is controlled by #setGraphData
|
||||
// It will only be true once the context has been loaded and initial payload received
|
||||
// #addGraphPoint should not be called prior to that since it means the data is racing
|
||||
// and the application has received updates prior to the initial state
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate isZoomed before mutating graphData otherwise the indexed values
|
||||
// are out of date and will always fail when compared to plotScaleX.min/max
|
||||
const plotScaleX = this._plotInstance.scales.x
|
||||
const isZoomed = plotScaleX.min > this._graphTimestamps[0] || plotScaleX.max < this._graphTimestamps[this._graphTimestamps.length - 1]
|
||||
const plotScaleX = this._plotInstance.scales.x;
|
||||
const isZoomed =
|
||||
plotScaleX.min > this._graphTimestamps[0] ||
|
||||
plotScaleX.max < this._graphTimestamps[this._graphTimestamps.length - 1];
|
||||
|
||||
this._graphTimestamps.push(timestamp)
|
||||
this._graphTimestamps.push(timestamp);
|
||||
|
||||
for (let i = 0; i < playerCounts.length; i++) {
|
||||
this._graphData[i].push(playerCounts[i])
|
||||
this._graphData[i].push(playerCounts[i]);
|
||||
}
|
||||
|
||||
// Trim all data arrays to only the relevant portion
|
||||
// This keeps it in sync with backend data structures
|
||||
const graphMaxLength = this._app.publicConfig.graphMaxLength
|
||||
const graphMaxLength = this._app.publicConfig.graphMaxLength;
|
||||
|
||||
if (this._graphTimestamps.length > graphMaxLength) {
|
||||
this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength)
|
||||
this._graphTimestamps.splice(
|
||||
0,
|
||||
this._graphTimestamps.length - graphMaxLength
|
||||
);
|
||||
}
|
||||
|
||||
for (const series of this._graphData) {
|
||||
if (series.length > graphMaxLength) {
|
||||
series.splice(0, series.length - graphMaxLength)
|
||||
series.splice(0, series.length - graphMaxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid redrawing the plot when zoomed
|
||||
this._plotInstance.setData(this.getGraphData(), !isZoomed)
|
||||
this._plotInstance.setData(this.getGraphData(), !isZoomed);
|
||||
}
|
||||
|
||||
loadLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY)
|
||||
loadLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const showOnlyFavorites = localStorage.getItem(
|
||||
SHOW_FAVORITES_STORAGE_KEY
|
||||
);
|
||||
if (showOnlyFavorites) {
|
||||
this._showOnlyFavorites = true
|
||||
this._showOnlyFavorites = true;
|
||||
}
|
||||
|
||||
// If only favorites mode is active, use the stored favorite servers data instead
|
||||
let serverNames
|
||||
let serverNames;
|
||||
if (this._showOnlyFavorites) {
|
||||
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
|
||||
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY);
|
||||
} else {
|
||||
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY)
|
||||
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
if (serverNames) {
|
||||
serverNames = JSON.parse(serverNames)
|
||||
serverNames = JSON.parse(serverNames);
|
||||
|
||||
// Iterate over all active serverRegistrations
|
||||
// This merges saved state with current state to prevent desyncs
|
||||
@ -83,122 +90,135 @@ export class GraphDisplayManager {
|
||||
// OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY
|
||||
// Checks between FAVORITE/HIDDEN keys are mutually exclusive
|
||||
if (this._showOnlyFavorites) {
|
||||
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0
|
||||
serverRegistration.isVisible =
|
||||
serverNames.indexOf(serverRegistration.data.name) >= 0;
|
||||
} else {
|
||||
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0
|
||||
serverRegistration.isVisible =
|
||||
serverNames.indexOf(serverRegistration.data.name) < 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
updateLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
// Mutate the serverIds array into server names for storage use
|
||||
const serverNames = this._app.serverRegistry.getServerRegistrations()
|
||||
.filter(serverRegistration => !serverRegistration.isVisible)
|
||||
.map(serverRegistration => serverRegistration.data.name)
|
||||
const serverNames = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.filter((serverRegistration) => !serverRegistration.isVisible)
|
||||
.map((serverRegistration) => serverRegistration.data.name);
|
||||
|
||||
// Only store if the array contains data, otherwise clear the item
|
||||
// If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead
|
||||
if (serverNames.length > 0 && !this._showOnlyFavorites) {
|
||||
localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
|
||||
localStorage.setItem(
|
||||
HIDDEN_SERVERS_STORAGE_KEY,
|
||||
JSON.stringify(serverNames)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY)
|
||||
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
// Only store SHOW_FAVORITES_STORAGE_KEY if true
|
||||
if (this._showOnlyFavorites) {
|
||||
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true)
|
||||
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true);
|
||||
} else {
|
||||
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY)
|
||||
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVisibleGraphData () {
|
||||
return this._app.serverRegistry.getServerRegistrations()
|
||||
.filter(serverRegistration => serverRegistration.isVisible)
|
||||
.map(serverRegistration => this._graphData[serverRegistration.serverId])
|
||||
getVisibleGraphData() {
|
||||
return this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.filter((serverRegistration) => serverRegistration.isVisible)
|
||||
.map(
|
||||
(serverRegistration) => this._graphData[serverRegistration.serverId]
|
||||
);
|
||||
}
|
||||
|
||||
getPlotSize () {
|
||||
getPlotSize() {
|
||||
return {
|
||||
width: Math.max(window.innerWidth, 800) * 0.9,
|
||||
height: 400
|
||||
height: 400,
|
||||
};
|
||||
}
|
||||
|
||||
getGraphData() {
|
||||
return [this._graphTimestamps, ...this._graphData];
|
||||
}
|
||||
|
||||
getGraphDataPoint(serverId, index) {
|
||||
const graphData = this._graphData[serverId];
|
||||
if (
|
||||
graphData &&
|
||||
index < graphData.length &&
|
||||
typeof graphData[index] === "number"
|
||||
) {
|
||||
return graphData[index];
|
||||
}
|
||||
}
|
||||
|
||||
getGraphData () {
|
||||
return [
|
||||
this._graphTimestamps,
|
||||
...this._graphData
|
||||
]
|
||||
}
|
||||
getClosestPlotSeriesIndex(idx) {
|
||||
let closestSeriesIndex = -1;
|
||||
let closestSeriesDist = Number.MAX_VALUE;
|
||||
|
||||
getGraphDataPoint (serverId, index) {
|
||||
const graphData = this._graphData[serverId]
|
||||
if (graphData && index < graphData.length && typeof graphData[index] === 'number') {
|
||||
return graphData[index]
|
||||
}
|
||||
}
|
||||
|
||||
getClosestPlotSeriesIndex (idx) {
|
||||
let closestSeriesIndex = -1
|
||||
let closestSeriesDist = Number.MAX_VALUE
|
||||
|
||||
const plotHeight = this._plotInstance.bbox.height / devicePixelRatio
|
||||
const plotHeight = this._plotInstance.bbox.height / devicePixelRatio;
|
||||
|
||||
for (let i = 1; i < this._plotInstance.series.length; i++) {
|
||||
const series = this._plotInstance.series[i]
|
||||
const series = this._plotInstance.series[i];
|
||||
|
||||
if (!series.show) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = this._plotInstance.data[i][idx]
|
||||
const point = this._plotInstance.data[i][idx];
|
||||
|
||||
if (typeof point === 'number') {
|
||||
const scale = this._plotInstance.scales[series.scale]
|
||||
const posY = (1 - ((point - scale.min) / (scale.max - scale.min))) * plotHeight
|
||||
if (typeof point === "number") {
|
||||
const scale = this._plotInstance.scales[series.scale];
|
||||
const posY =
|
||||
(1 - (point - scale.min) / (scale.max - scale.min)) * plotHeight;
|
||||
|
||||
const dist = Math.abs(posY - this._plotInstance.cursor.top)
|
||||
const dist = Math.abs(posY - this._plotInstance.cursor.top);
|
||||
|
||||
if (dist < closestSeriesDist) {
|
||||
closestSeriesIndex = i
|
||||
closestSeriesDist = dist
|
||||
closestSeriesIndex = i;
|
||||
closestSeriesDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closestSeriesIndex
|
||||
return closestSeriesIndex;
|
||||
}
|
||||
|
||||
buildPlotInstance (timestamps, data) {
|
||||
buildPlotInstance(timestamps, data) {
|
||||
// Lazy load settings from localStorage, if any and if enabled
|
||||
if (!this._hasLoadedSettings) {
|
||||
this._hasLoadedSettings = true
|
||||
this._hasLoadedSettings = true;
|
||||
|
||||
this.loadLocalStorage()
|
||||
this.loadLocalStorage();
|
||||
}
|
||||
|
||||
for (const playerCounts of data) {
|
||||
// Each playerCounts value corresponds to a ServerRegistration
|
||||
// Require each array is the length of timestamps, if not, pad at the start with null values to fit to length
|
||||
// This ensures newer ServerRegistrations do not left align due to a lower length
|
||||
const lengthDiff = timestamps.length - playerCounts.length
|
||||
const lengthDiff = timestamps.length - playerCounts.length;
|
||||
|
||||
if (lengthDiff > 0) {
|
||||
const padding = Array(lengthDiff).fill(null)
|
||||
const padding = Array(lengthDiff).fill(null);
|
||||
|
||||
playerCounts.unshift(...padding)
|
||||
playerCounts.unshift(...padding);
|
||||
}
|
||||
}
|
||||
|
||||
this._graphTimestamps = timestamps
|
||||
this._graphData = data
|
||||
this._graphTimestamps = timestamps;
|
||||
this._graphData = data;
|
||||
|
||||
const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => {
|
||||
const series = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.map((serverRegistration) => {
|
||||
return {
|
||||
stroke: serverRegistration.data.color,
|
||||
width: 2,
|
||||
@ -206,261 +226,293 @@ export class GraphDisplayManager {
|
||||
show: serverRegistration.isVisible,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
})
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const tickCount = 10
|
||||
const maxFactor = 4
|
||||
const tickCount = 10;
|
||||
const maxFactor = 4;
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
this._plotInstance = new uPlot({
|
||||
this._plotInstance = new uPlot(
|
||||
{
|
||||
plugins: [
|
||||
uPlotTooltipPlugin((pos, idx) => {
|
||||
if (pos) {
|
||||
const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx)
|
||||
const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx);
|
||||
|
||||
const text = this._app.serverRegistry.getServerRegistrations()
|
||||
.filter(serverRegistration => serverRegistration.isVisible)
|
||||
const text =
|
||||
this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.filter((serverRegistration) => serverRegistration.isVisible)
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite !== b.isFavorite) {
|
||||
return a.isFavorite ? -1 : 1
|
||||
return a.isFavorite ? -1 : 1;
|
||||
} else {
|
||||
return a.data.name.localeCompare(b.data.name)
|
||||
return a.data.name.localeCompare(b.data.name);
|
||||
}
|
||||
})
|
||||
.map(serverRegistration => {
|
||||
const point = this.getGraphDataPoint(serverRegistration.serverId, idx)
|
||||
.map((serverRegistration) => {
|
||||
const point = this.getGraphDataPoint(
|
||||
serverRegistration.serverId,
|
||||
idx
|
||||
);
|
||||
|
||||
let serverName = serverRegistration.data.name
|
||||
if (closestSeriesIndex === serverRegistration.getGraphDataIndex()) {
|
||||
serverName = `<strong>${serverName}</strong>`
|
||||
let serverName = serverRegistration.data.name;
|
||||
if (
|
||||
closestSeriesIndex ===
|
||||
serverRegistration.getGraphDataIndex()
|
||||
) {
|
||||
serverName = `<strong>${serverName}</strong>`;
|
||||
}
|
||||
if (serverRegistration.isFavorite) {
|
||||
serverName = `<span class="${this._app.favoritesManager.getIconClass(true)}"></span> ${serverName}`
|
||||
serverName = `<span class="${this._app.favoritesManager.getIconClass(
|
||||
true
|
||||
)}"></span> ${serverName}`;
|
||||
}
|
||||
|
||||
return `${serverName}: ${formatNumber(point)}`
|
||||
}).join('<br>') + `<br><br><strong>${formatTimestampSeconds(this._graphTimestamps[idx])}</strong>`
|
||||
|
||||
this._app.tooltip.set(pos.left, pos.top, 10, 10, text)
|
||||
} else {
|
||||
this._app.tooltip.hide()
|
||||
}
|
||||
return `${serverName}: ${formatNumber(point)}`;
|
||||
})
|
||||
.join("<br>") +
|
||||
`<br><br><strong>${formatTimestampSeconds(
|
||||
this._graphTimestamps[idx]
|
||||
)}</strong>`;
|
||||
|
||||
this._app.tooltip.set(pos.left, pos.top, 10, 10, text);
|
||||
} else {
|
||||
this._app.tooltip.hide();
|
||||
}
|
||||
}),
|
||||
],
|
||||
...this.getPlotSize(),
|
||||
cursor: {
|
||||
y: false
|
||||
y: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
},
|
||||
...series
|
||||
],
|
||||
series: [{}, ...series],
|
||||
axes: [
|
||||
{
|
||||
font: '14px "Open Sans", sans-serif',
|
||||
stroke: '#FFF',
|
||||
stroke: "#FFF",
|
||||
grid: {
|
||||
show: false
|
||||
show: false,
|
||||
},
|
||||
space: 60
|
||||
space: 60,
|
||||
},
|
||||
{
|
||||
font: '14px "Open Sans", sans-serif',
|
||||
stroke: '#FFF',
|
||||
stroke: "#FFF",
|
||||
size: 65,
|
||||
grid: {
|
||||
stroke: '#333',
|
||||
width: 1
|
||||
stroke: "#333",
|
||||
width: 1,
|
||||
},
|
||||
split: () => {
|
||||
const visibleGraphData = this.getVisibleGraphData()
|
||||
const { scaledMax, scale } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor)
|
||||
const ticks = RelativeScale.generateTicks(0, scaledMax, scale)
|
||||
return ticks
|
||||
}
|
||||
}
|
||||
const visibleGraphData = this.getVisibleGraphData();
|
||||
const { scaledMax, scale } = RelativeScale.scaleMatrix(
|
||||
visibleGraphData,
|
||||
tickCount,
|
||||
maxFactor
|
||||
);
|
||||
const ticks = RelativeScale.generateTicks(0, scaledMax, scale);
|
||||
return ticks;
|
||||
},
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
y: {
|
||||
auto: false,
|
||||
range: () => {
|
||||
const visibleGraphData = this.getVisibleGraphData()
|
||||
const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor)
|
||||
return [scaledMin, scaledMax]
|
||||
}
|
||||
}
|
||||
const visibleGraphData = this.getVisibleGraphData();
|
||||
const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(
|
||||
visibleGraphData,
|
||||
tickCount,
|
||||
maxFactor
|
||||
);
|
||||
return [scaledMin, scaledMax];
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
}, this.getGraphData(), document.getElementById('big-graph'))
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
this.getGraphData(),
|
||||
document.getElementById("big-graph")
|
||||
);
|
||||
|
||||
// Show the settings-toggle element
|
||||
document.getElementById('settings-toggle').style.display = 'inline-block'
|
||||
document.getElementById("settings-toggle").style.display = "inline-block";
|
||||
}
|
||||
|
||||
redraw = () => {
|
||||
// Use drawing as a hint to update settings
|
||||
// This may cause unnessecary localStorage updates, but its a rare and harmless outcome
|
||||
this.updateLocalStorage()
|
||||
this.updateLocalStorage();
|
||||
|
||||
// Copy application state into the series data used by uPlot
|
||||
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
|
||||
this._plotInstance.series[serverRegistration.getGraphDataIndex()].show = serverRegistration.isVisible
|
||||
this._plotInstance.series[serverRegistration.getGraphDataIndex()].show =
|
||||
serverRegistration.isVisible;
|
||||
}
|
||||
|
||||
this._plotInstance.redraw()
|
||||
}
|
||||
this._plotInstance.redraw();
|
||||
};
|
||||
|
||||
requestResize () {
|
||||
requestResize() {
|
||||
// Only resize when _plotInstance is defined
|
||||
// Set a timeout to resize after resize events have not been fired for some duration of time
|
||||
// This prevents burning CPU time for multiple, rapid resize events
|
||||
if (this._plotInstance) {
|
||||
if (this._resizeRequestTimeout) {
|
||||
clearTimeout(this._resizeRequestTimeout)
|
||||
clearTimeout(this._resizeRequestTimeout);
|
||||
}
|
||||
|
||||
// Schedule new delayed resize call
|
||||
// This can be cancelled by #requestResize, #resize and #reset
|
||||
this._resizeRequestTimeout = setTimeout(this.resize, 200)
|
||||
this._resizeRequestTimeout = setTimeout(this.resize, 200);
|
||||
}
|
||||
}
|
||||
|
||||
resize = () => {
|
||||
this._plotInstance.setSize(this.getPlotSize())
|
||||
this._plotInstance.setSize(this.getPlotSize());
|
||||
|
||||
// undefine value so #clearTimeout is not called
|
||||
// This is safe even if #resize is manually called since it removes the pending work
|
||||
if (this._resizeRequestTimeout) {
|
||||
clearTimeout(this._resizeRequestTimeout)
|
||||
clearTimeout(this._resizeRequestTimeout);
|
||||
}
|
||||
|
||||
this._resizeRequestTimeout = undefined
|
||||
}
|
||||
this._resizeRequestTimeout = undefined;
|
||||
};
|
||||
|
||||
initEventListeners () {
|
||||
initEventListeners() {
|
||||
if (!this._initEventListenersOnce) {
|
||||
this._initEventListenersOnce = true
|
||||
this._initEventListenersOnce = true;
|
||||
|
||||
// These listeners should only be init once since they attach to persistent elements
|
||||
document.getElementById('settings-toggle').addEventListener('click', this.handleSettingsToggle, false)
|
||||
document
|
||||
.getElementById("settings-toggle")
|
||||
.addEventListener("click", this.handleSettingsToggle, false);
|
||||
|
||||
document.querySelectorAll('.graph-controls-show').forEach((element) => {
|
||||
element.addEventListener('click', this.handleShowButtonClick, false)
|
||||
})
|
||||
document.querySelectorAll(".graph-controls-show").forEach((element) => {
|
||||
element.addEventListener("click", this.handleShowButtonClick, false);
|
||||
});
|
||||
}
|
||||
|
||||
// These listeners should be bound each #initEventListeners call since they are for newly created elements
|
||||
document.querySelectorAll('.graph-control').forEach((element) => {
|
||||
element.addEventListener('click', this.handleServerButtonClick, false)
|
||||
})
|
||||
document.querySelectorAll(".graph-control").forEach((element) => {
|
||||
element.addEventListener("click", this.handleServerButtonClick, false);
|
||||
});
|
||||
}
|
||||
|
||||
handleServerButtonClick = (event) => {
|
||||
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
|
||||
const serverId = parseInt(event.target.getAttribute("minetrack-server-id"));
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverId);
|
||||
|
||||
if (serverRegistration.isVisible !== event.target.checked) {
|
||||
serverRegistration.isVisible = event.target.checked
|
||||
serverRegistration.isVisible = event.target.checked;
|
||||
|
||||
// Any manual changes automatically disables "Only Favorites" mode
|
||||
// Otherwise the auto management might overwrite their manual changes
|
||||
this._showOnlyFavorites = false
|
||||
this._showOnlyFavorites = false;
|
||||
|
||||
this.redraw()
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
};
|
||||
|
||||
handleShowButtonClick = (event) => {
|
||||
const showType = event.target.getAttribute('minetrack-show-type')
|
||||
const showType = event.target.getAttribute("minetrack-show-type");
|
||||
|
||||
// If set to "Only Favorites", set internal state so that
|
||||
// visible graphData is automatically updating when a ServerRegistration's #isVisible changes
|
||||
// This is also saved and loaded by #loadLocalStorage & #updateLocalStorage
|
||||
this._showOnlyFavorites = showType === 'favorites'
|
||||
this._showOnlyFavorites = showType === "favorites";
|
||||
|
||||
let redraw = false
|
||||
let redraw = false;
|
||||
|
||||
this._app.serverRegistry.getServerRegistrations().forEach(function (serverRegistration) {
|
||||
let isVisible
|
||||
if (showType === 'all') {
|
||||
isVisible = true
|
||||
} else if (showType === 'none') {
|
||||
isVisible = false
|
||||
} else if (showType === 'favorites') {
|
||||
isVisible = serverRegistration.isFavorite
|
||||
this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.forEach(function (serverRegistration) {
|
||||
let isVisible;
|
||||
if (showType === "all") {
|
||||
isVisible = true;
|
||||
} else if (showType === "none") {
|
||||
isVisible = false;
|
||||
} else if (showType === "favorites") {
|
||||
isVisible = serverRegistration.isFavorite;
|
||||
}
|
||||
|
||||
if (serverRegistration.isVisible !== isVisible) {
|
||||
serverRegistration.isVisible = isVisible
|
||||
redraw = true
|
||||
serverRegistration.isVisible = isVisible;
|
||||
redraw = true;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (redraw) {
|
||||
this.redraw()
|
||||
this.updateCheckboxes()
|
||||
}
|
||||
this.redraw();
|
||||
this.updateCheckboxes();
|
||||
}
|
||||
};
|
||||
|
||||
handleSettingsToggle = () => {
|
||||
const element = document.getElementById('big-graph-controls-drawer')
|
||||
const element = document.getElementById("big-graph-controls-drawer");
|
||||
|
||||
if (element.style.display !== 'block') {
|
||||
element.style.display = 'block'
|
||||
if (element.style.display !== "block") {
|
||||
element.style.display = "block";
|
||||
} else {
|
||||
element.style.display = 'none'
|
||||
}
|
||||
element.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
handleServerIsFavoriteUpdate = (serverRegistration) => {
|
||||
// When in "Only Favorites" mode, visibility is dependent on favorite status
|
||||
// Redraw and update elements as needed
|
||||
if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) {
|
||||
serverRegistration.isVisible = serverRegistration.isFavorite
|
||||
if (
|
||||
this._showOnlyFavorites &&
|
||||
serverRegistration.isVisible !== serverRegistration.isFavorite
|
||||
) {
|
||||
serverRegistration.isVisible = serverRegistration.isFavorite;
|
||||
|
||||
this.redraw()
|
||||
this.updateCheckboxes()
|
||||
this.redraw();
|
||||
this.updateCheckboxes();
|
||||
}
|
||||
};
|
||||
|
||||
updateCheckboxes() {
|
||||
document.querySelectorAll(".graph-control").forEach((checkbox) => {
|
||||
const serverId = parseInt(checkbox.getAttribute("minetrack-server-id"));
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverId);
|
||||
|
||||
checkbox.checked = serverRegistration.isVisible;
|
||||
});
|
||||
}
|
||||
|
||||
updateCheckboxes () {
|
||||
document.querySelectorAll('.graph-control').forEach((checkbox) => {
|
||||
const serverId = parseInt(checkbox.getAttribute('minetrack-server-id'))
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
|
||||
|
||||
checkbox.checked = serverRegistration.isVisible
|
||||
})
|
||||
}
|
||||
|
||||
reset () {
|
||||
reset() {
|
||||
// Destroy graphs and unload references
|
||||
// uPlot#destroy handles listener de-registration, DOM reset, etc
|
||||
if (this._plotInstance) {
|
||||
this._plotInstance.destroy()
|
||||
this._plotInstance = undefined
|
||||
this._plotInstance.destroy();
|
||||
this._plotInstance = undefined;
|
||||
}
|
||||
|
||||
this._graphTimestamps = []
|
||||
this._graphData = []
|
||||
this._hasLoadedSettings = false
|
||||
this._graphTimestamps = [];
|
||||
this._graphData = [];
|
||||
this._hasLoadedSettings = false;
|
||||
|
||||
// Fire #clearTimeout if the timeout is currently defined
|
||||
if (this._resizeRequestTimeout) {
|
||||
clearTimeout(this._resizeRequestTimeout)
|
||||
clearTimeout(this._resizeRequestTimeout);
|
||||
|
||||
this._resizeRequestTimeout = undefined
|
||||
this._resizeRequestTimeout = undefined;
|
||||
}
|
||||
|
||||
// Reset modified DOM structures
|
||||
document.getElementById('big-graph-checkboxes').innerHTML = ''
|
||||
document.getElementById('big-graph-controls').style.display = 'none'
|
||||
document.getElementById("big-graph-checkboxes").innerHTML = "";
|
||||
document.getElementById("big-graph-controls").style.display = "none";
|
||||
|
||||
document.getElementById('settings-toggle').style.display = 'none'
|
||||
document.getElementById("settings-toggle").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,22 @@
|
||||
import { App } from './app'
|
||||
import { App } from "./app";
|
||||
|
||||
const app = new App()
|
||||
const app = new App();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
app.init()
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
app.init();
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
app.percentageBar.redraw()
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
function () {
|
||||
app.percentageBar.redraw();
|
||||
|
||||
// Delegate to GraphDisplayManager which can check if the resize is necessary
|
||||
app.graphDisplayManager.requestResize()
|
||||
}, false)
|
||||
}, false)
|
||||
app.graphDisplayManager.requestResize();
|
||||
},
|
||||
false
|
||||
);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
@ -1,75 +1,99 @@
|
||||
import { formatNumber, formatPercent } from './util'
|
||||
import { formatNumber, formatPercent } from "./util";
|
||||
|
||||
export class PercentageBar {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._parent = document.getElementById('perc-bar')
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._parent = document.getElementById("perc-bar");
|
||||
}
|
||||
|
||||
redraw = () => {
|
||||
const serverRegistrations = this._app.serverRegistry.getServerRegistrations().sort(function (a, b) {
|
||||
return a.playerCount - b.playerCount
|
||||
})
|
||||
const serverRegistrations = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.sort(function (a, b) {
|
||||
return a.playerCount - b.playerCount;
|
||||
});
|
||||
|
||||
const totalPlayers = this._app.getTotalPlayerCount()
|
||||
const totalPlayers = this._app.getTotalPlayerCount();
|
||||
|
||||
let leftPadding = 0
|
||||
let leftPadding = 0;
|
||||
|
||||
for (const serverRegistration of serverRegistrations) {
|
||||
const width = Math.round((serverRegistration.playerCount / totalPlayers) * this._parent.offsetWidth)
|
||||
const width = Math.round(
|
||||
(serverRegistration.playerCount / totalPlayers) *
|
||||
this._parent.offsetWidth
|
||||
);
|
||||
|
||||
// Update position/width
|
||||
// leftPadding is a sum of previous iterations width value
|
||||
const div = document.getElementById(`perc-bar-part_${serverRegistration.serverId}`) || this.createPart(serverRegistration)
|
||||
const div =
|
||||
document.getElementById(
|
||||
`perc-bar-part_${serverRegistration.serverId}`
|
||||
) || this.createPart(serverRegistration);
|
||||
|
||||
const widthPixels = `${width}px`
|
||||
const leftPaddingPixels = `${leftPadding}px`
|
||||
const widthPixels = `${width}px`;
|
||||
const leftPaddingPixels = `${leftPadding}px`;
|
||||
|
||||
// Only redraw if needed
|
||||
if (div.style.width !== widthPixels || div.style.left !== leftPaddingPixels) {
|
||||
div.style.width = widthPixels
|
||||
div.style.left = leftPaddingPixels
|
||||
if (
|
||||
div.style.width !== widthPixels ||
|
||||
div.style.left !== leftPaddingPixels
|
||||
) {
|
||||
div.style.width = widthPixels;
|
||||
div.style.left = leftPaddingPixels;
|
||||
}
|
||||
|
||||
leftPadding += width
|
||||
}
|
||||
leftPadding += width;
|
||||
}
|
||||
};
|
||||
|
||||
createPart (serverRegistration) {
|
||||
const div = document.createElement('div')
|
||||
createPart(serverRegistration) {
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.id = `perc-bar-part_${serverRegistration.serverId}`
|
||||
div.style.background = serverRegistration.data.color
|
||||
div.id = `perc-bar-part_${serverRegistration.serverId}`;
|
||||
div.style.background = serverRegistration.data.color;
|
||||
|
||||
div.setAttribute('class', 'perc-bar-part')
|
||||
div.setAttribute('minetrack-server-id', serverRegistration.serverId)
|
||||
div.setAttribute("class", "perc-bar-part");
|
||||
div.setAttribute("minetrack-server-id", serverRegistration.serverId);
|
||||
|
||||
this._parent.appendChild(div)
|
||||
this._parent.appendChild(div);
|
||||
|
||||
// Define events once during creation
|
||||
div.addEventListener('mouseover', this.handleMouseOver, false)
|
||||
div.addEventListener('mouseout', this.handleMouseOut, false)
|
||||
div.addEventListener("mouseover", this.handleMouseOver, false);
|
||||
div.addEventListener("mouseout", this.handleMouseOut, false);
|
||||
|
||||
return div
|
||||
return div;
|
||||
}
|
||||
|
||||
handleMouseOver = (event) => {
|
||||
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
|
||||
const serverId = parseInt(event.target.getAttribute("minetrack-server-id"));
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverId);
|
||||
|
||||
this._app.tooltip.set(event.target.offsetLeft, event.target.offsetTop, 10, this._parent.offsetTop + this._parent.offsetHeight + 10,
|
||||
`${typeof serverRegistration.rankIndex !== 'undefined' ? `#${serverRegistration.rankIndex + 1} ` : ''}
|
||||
this._app.tooltip.set(
|
||||
event.target.offsetLeft,
|
||||
event.target.offsetTop,
|
||||
10,
|
||||
this._parent.offsetTop + this._parent.offsetHeight + 10,
|
||||
`${
|
||||
typeof serverRegistration.rankIndex !== "undefined"
|
||||
? `#${serverRegistration.rankIndex + 1} `
|
||||
: ""
|
||||
}
|
||||
${serverRegistration.data.name}<br>
|
||||
${formatNumber(serverRegistration.playerCount)} Players<br>
|
||||
<strong>${formatPercent(serverRegistration.playerCount, this._app.getTotalPlayerCount())}</strong>`)
|
||||
}
|
||||
<strong>${formatPercent(
|
||||
serverRegistration.playerCount,
|
||||
this._app.getTotalPlayerCount()
|
||||
)}</strong>`
|
||||
);
|
||||
};
|
||||
|
||||
handleMouseOut = () => {
|
||||
this._app.tooltip.hide()
|
||||
}
|
||||
this._app.tooltip.hide();
|
||||
};
|
||||
|
||||
reset () {
|
||||
reset() {
|
||||
// Reset modified DOM elements
|
||||
this._parent.innerHTML = ''
|
||||
this._parent.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +1,31 @@
|
||||
export function uPlotTooltipPlugin (onHover) {
|
||||
let element
|
||||
export function uPlotTooltipPlugin(onHover) {
|
||||
let element;
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
init: u => {
|
||||
element = u.root.querySelector('.over')
|
||||
init: (u) => {
|
||||
element = u.root.querySelector(".over");
|
||||
|
||||
element.onmouseenter = () => onHover()
|
||||
element.onmouseleave = () => onHover()
|
||||
element.onmouseenter = () => onHover();
|
||||
element.onmouseleave = () => onHover();
|
||||
},
|
||||
setCursor: u => {
|
||||
const { left, top, idx } = u.cursor
|
||||
setCursor: (u) => {
|
||||
const { left, top, idx } = u.cursor;
|
||||
|
||||
if (idx === null) {
|
||||
onHover()
|
||||
onHover();
|
||||
} else {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const bounds = element.getBoundingClientRect();
|
||||
|
||||
onHover({
|
||||
onHover(
|
||||
{
|
||||
left: bounds.left + left + window.pageXOffset,
|
||||
top: bounds.top + top + window.pageYOffset
|
||||
}, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
top: bounds.top + top + window.pageYOffset,
|
||||
},
|
||||
idx
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,88 +1,91 @@
|
||||
export class RelativeScale {
|
||||
static scale (data, tickCount, maxFactor) {
|
||||
const { min, max } = RelativeScale.calculateBounds(data)
|
||||
static scale(data, tickCount, maxFactor) {
|
||||
const { min, max } = RelativeScale.calculateBounds(data);
|
||||
|
||||
let factor = 1
|
||||
let factor = 1;
|
||||
|
||||
while (true) {
|
||||
const scale = Math.pow(10, factor)
|
||||
const scale = Math.pow(10, factor);
|
||||
|
||||
const scaledMin = min - (min % scale)
|
||||
let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale))
|
||||
const scaledMin = min - (min % scale);
|
||||
let scaledMax = max + (max % scale === 0 ? 0 : scale - (max % scale));
|
||||
|
||||
// Prevent min/max from being equal (and generating 0 ticks)
|
||||
// This happens when all data points are products of scale value
|
||||
if (scaledMin === scaledMax) {
|
||||
scaledMax += scale
|
||||
scaledMax += scale;
|
||||
}
|
||||
|
||||
const ticks = (scaledMax - scaledMin) / scale
|
||||
const ticks = (scaledMax - scaledMin) / scale;
|
||||
|
||||
if (ticks <= tickCount || (typeof maxFactor === 'number' && factor === maxFactor)) {
|
||||
if (
|
||||
ticks <= tickCount ||
|
||||
(typeof maxFactor === "number" && factor === maxFactor)
|
||||
) {
|
||||
return {
|
||||
scaledMin,
|
||||
scaledMax,
|
||||
scale
|
||||
}
|
||||
scale,
|
||||
};
|
||||
} else {
|
||||
// Too many steps between min/max, increase factor and try again
|
||||
factor++
|
||||
factor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static scaleMatrix (data, tickCount, maxFactor) {
|
||||
const nonNullData = data.flat().filter((val) => val !== null)
|
||||
static scaleMatrix(data, tickCount, maxFactor) {
|
||||
const nonNullData = data.flat().filter((val) => val !== null);
|
||||
|
||||
// when used with the spread operator large nonNullData/data arrays can reach the max call stack size
|
||||
// use reduce calls to safely determine min/max values for any size of array
|
||||
// https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516
|
||||
const max = nonNullData.reduce((a, b) => {
|
||||
return Math.max(a, b)
|
||||
}, Number.NEGATIVE_INFINITY)
|
||||
return Math.max(a, b);
|
||||
}, Number.NEGATIVE_INFINITY);
|
||||
|
||||
return RelativeScale.scale(
|
||||
[0, RelativeScale.isFiniteOrZero(max)],
|
||||
tickCount,
|
||||
maxFactor
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static generateTicks (min, max, step) {
|
||||
const ticks = []
|
||||
static generateTicks(min, max, step) {
|
||||
const ticks = [];
|
||||
for (let i = min; i <= max; i += step) {
|
||||
ticks.push(i)
|
||||
ticks.push(i);
|
||||
}
|
||||
return ticks
|
||||
return ticks;
|
||||
}
|
||||
|
||||
static calculateBounds (data) {
|
||||
static calculateBounds(data) {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
min: 0,
|
||||
max: 0
|
||||
}
|
||||
max: 0,
|
||||
};
|
||||
} else {
|
||||
const nonNullData = data.filter((val) => val !== null)
|
||||
const nonNullData = data.filter((val) => val !== null);
|
||||
|
||||
// when used with the spread operator large nonNullData/data arrays can reach the max call stack size
|
||||
// use reduce calls to safely determine min/max values for any size of array
|
||||
// https://stackoverflow.com/questions/63705432/maximum-call-stack-size-exceeded-when-using-the-dots-operator/63706516#63706516
|
||||
const min = nonNullData.reduce((a, b) => {
|
||||
return Math.min(a, b)
|
||||
}, Number.POSITIVE_INFINITY)
|
||||
return Math.min(a, b);
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
const max = nonNullData.reduce((a, b) => {
|
||||
return Math.max(a, b)
|
||||
}, Number.NEGATIVE_INFINITY)
|
||||
return Math.max(a, b);
|
||||
}, Number.NEGATIVE_INFINITY);
|
||||
|
||||
return {
|
||||
min: RelativeScale.isFiniteOrZero(min),
|
||||
max: RelativeScale.isFiniteOrZero(max)
|
||||
}
|
||||
max: RelativeScale.isFiniteOrZero(max),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static isFiniteOrZero (val) {
|
||||
return Number.isFinite(val) ? val : 0
|
||||
static isFiniteOrZero(val) {
|
||||
return Number.isFinite(val) ? val : 0;
|
||||
}
|
||||
}
|
||||
|
@ -1,102 +1,120 @@
|
||||
import uPlot from 'uplot'
|
||||
import uPlot from "uplot";
|
||||
|
||||
import { RelativeScale } from './scale'
|
||||
import { RelativeScale } from "./scale";
|
||||
|
||||
import { formatNumber, formatTimestampSeconds, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util'
|
||||
import { uPlotTooltipPlugin } from './plugins'
|
||||
import { uPlotTooltipPlugin } from "./plugins";
|
||||
import {
|
||||
formatDate,
|
||||
formatMinecraftServerAddress,
|
||||
formatMinecraftVersions,
|
||||
formatNumber,
|
||||
formatTimestampSeconds,
|
||||
} from "./util";
|
||||
|
||||
import MISSING_FAVICON from 'url:../images/missing_favicon.svg'
|
||||
import MISSING_FAVICON from "url:../images/missing_favicon.svg";
|
||||
|
||||
export class ServerRegistry {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._serverIdsByName = []
|
||||
this._serverDataById = []
|
||||
this._registeredServers = []
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._serverIdsByName = [];
|
||||
this._serverDataById = [];
|
||||
this._registeredServers = [];
|
||||
}
|
||||
|
||||
assignServers (servers) {
|
||||
assignServers(servers) {
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const data = servers[i]
|
||||
this._serverIdsByName[data.name] = i
|
||||
this._serverDataById[i] = data
|
||||
const data = servers[i];
|
||||
this._serverIdsByName[data.name] = i;
|
||||
this._serverDataById[i] = data;
|
||||
}
|
||||
}
|
||||
|
||||
createServerRegistration (serverId) {
|
||||
const serverData = this._serverDataById[serverId]
|
||||
const serverRegistration = new ServerRegistration(this._app, serverId, serverData)
|
||||
this._registeredServers[serverId] = serverRegistration
|
||||
return serverRegistration
|
||||
createServerRegistration(serverId) {
|
||||
const serverData = this._serverDataById[serverId];
|
||||
const serverRegistration = new ServerRegistration(
|
||||
this._app,
|
||||
serverId,
|
||||
serverData
|
||||
);
|
||||
this._registeredServers[serverId] = serverRegistration;
|
||||
return serverRegistration;
|
||||
}
|
||||
|
||||
getServerRegistration (serverKey) {
|
||||
if (typeof serverKey === 'string') {
|
||||
const serverId = this._serverIdsByName[serverKey]
|
||||
return this._registeredServers[serverId]
|
||||
} else if (typeof serverKey === 'number') {
|
||||
return this._registeredServers[serverKey]
|
||||
getServerRegistration(serverKey) {
|
||||
if (typeof serverKey === "string") {
|
||||
const serverId = this._serverIdsByName[serverKey];
|
||||
return this._registeredServers[serverId];
|
||||
} else if (typeof serverKey === "number") {
|
||||
return this._registeredServers[serverKey];
|
||||
}
|
||||
}
|
||||
|
||||
getServerRegistrations = () => Object.values(this._registeredServers)
|
||||
getServerRegistrations = () => Object.values(this._registeredServers);
|
||||
|
||||
reset () {
|
||||
this._serverIdsByName = []
|
||||
this._serverDataById = []
|
||||
this._registeredServers = []
|
||||
reset() {
|
||||
this._serverIdsByName = [];
|
||||
this._serverDataById = [];
|
||||
this._registeredServers = [];
|
||||
|
||||
// Reset modified DOM structures
|
||||
document.getElementById('server-list').innerHTML = ''
|
||||
document.getElementById("server-list").innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerRegistration {
|
||||
playerCount = 0
|
||||
isVisible = true
|
||||
isFavorite = false
|
||||
rankIndex
|
||||
lastRecordData
|
||||
lastPeakData
|
||||
playerCount = 0;
|
||||
isVisible = true;
|
||||
isFavorite = false;
|
||||
rankIndex;
|
||||
lastRecordData;
|
||||
lastPeakData;
|
||||
|
||||
constructor (app, serverId, data) {
|
||||
this._app = app
|
||||
this.serverId = serverId
|
||||
this.data = data
|
||||
this._graphData = [[], []]
|
||||
this._failedSequentialPings = 0
|
||||
constructor(app, serverId, data) {
|
||||
this._app = app;
|
||||
this.serverId = serverId;
|
||||
this.data = data;
|
||||
this._graphData = [[], []];
|
||||
this._failedSequentialPings = 0;
|
||||
}
|
||||
|
||||
getGraphDataIndex () {
|
||||
return this.serverId + 1
|
||||
getGraphDataIndex() {
|
||||
return this.serverId + 1;
|
||||
}
|
||||
|
||||
addGraphPoints (points, timestampPoints) {
|
||||
this._graphData = [
|
||||
timestampPoints.slice(),
|
||||
points
|
||||
]
|
||||
addGraphPoints(points, timestampPoints) {
|
||||
this._graphData = [timestampPoints.slice(), points];
|
||||
}
|
||||
|
||||
buildPlotInstance () {
|
||||
const tickCount = 4
|
||||
buildPlotInstance() {
|
||||
const tickCount = 4;
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
this._plotInstance = new uPlot({
|
||||
this._plotInstance = new uPlot(
|
||||
{
|
||||
plugins: [
|
||||
uPlotTooltipPlugin((pos, id) => {
|
||||
if (pos) {
|
||||
const playerCount = this._graphData[1][id]
|
||||
const playerCount = this._graphData[1][id];
|
||||
|
||||
if (typeof playerCount !== 'number') {
|
||||
this._app.tooltip.hide()
|
||||
if (typeof playerCount !== "number") {
|
||||
this._app.tooltip.hide();
|
||||
} else {
|
||||
this._app.tooltip.set(pos.left, pos.top, 10, 10, `${formatNumber(playerCount)} Players<br>${formatTimestampSeconds(this._graphData[0][id])}`)
|
||||
this._app.tooltip.set(
|
||||
pos.left,
|
||||
pos.top,
|
||||
10,
|
||||
10,
|
||||
`${formatNumber(
|
||||
playerCount
|
||||
)} Players<br>${formatTimestampSeconds(
|
||||
this._graphData[0][id]
|
||||
)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._app.tooltip.hide()
|
||||
this._app.tooltip.hide();
|
||||
}
|
||||
})
|
||||
}),
|
||||
],
|
||||
height: 100,
|
||||
width: 400,
|
||||
@ -105,215 +123,274 @@ export class ServerRegistration {
|
||||
drag: {
|
||||
setScale: false,
|
||||
x: false,
|
||||
y: false
|
||||
y: false,
|
||||
},
|
||||
sync: {
|
||||
key: 'minetrack-server',
|
||||
setSeries: true
|
||||
}
|
||||
key: "minetrack-server",
|
||||
setSeries: true,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: '#E9E581',
|
||||
stroke: "#E9E581",
|
||||
width: 2,
|
||||
value: (_, raw) => `${formatNumber(raw)} Players`,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
show: false
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
ticks: {
|
||||
show: false
|
||||
show: false,
|
||||
},
|
||||
font: '14px "Open Sans", sans-serif',
|
||||
stroke: '#A3A3A3',
|
||||
stroke: "#A3A3A3",
|
||||
size: 55,
|
||||
grid: {
|
||||
stroke: '#333',
|
||||
width: 1
|
||||
stroke: "#333",
|
||||
width: 1,
|
||||
},
|
||||
split: () => {
|
||||
const { scaledMin, scaledMax, scale } = RelativeScale.scale(this._graphData[1], tickCount)
|
||||
const ticks = RelativeScale.generateTicks(scaledMin, scaledMax, scale)
|
||||
return ticks
|
||||
}
|
||||
}
|
||||
const { scaledMin, scaledMax, scale } = RelativeScale.scale(
|
||||
this._graphData[1],
|
||||
tickCount
|
||||
);
|
||||
const ticks = RelativeScale.generateTicks(
|
||||
scaledMin,
|
||||
scaledMax,
|
||||
scale
|
||||
);
|
||||
return ticks;
|
||||
},
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
y: {
|
||||
auto: false,
|
||||
range: () => {
|
||||
const { scaledMin, scaledMax } = RelativeScale.scale(this._graphData[1], tickCount)
|
||||
return [scaledMin, scaledMax]
|
||||
}
|
||||
}
|
||||
const { scaledMin, scaledMax } = RelativeScale.scale(
|
||||
this._graphData[1],
|
||||
tickCount
|
||||
);
|
||||
return [scaledMin, scaledMax];
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
}, this._graphData, document.getElementById(`chart_${this.serverId}`))
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
this._graphData,
|
||||
document.getElementById(`chart_${this.serverId}`)
|
||||
);
|
||||
}
|
||||
|
||||
handlePing (payload, timestamp) {
|
||||
if (typeof payload.playerCount === 'number') {
|
||||
this.playerCount = payload.playerCount
|
||||
handlePing(payload, timestamp) {
|
||||
if (typeof payload.playerCount === "number") {
|
||||
this.playerCount = payload.playerCount;
|
||||
|
||||
// Reset failed ping counter to ensure the next connection error
|
||||
// doesn't instantly retrigger a layout change
|
||||
this._failedSequentialPings = 0
|
||||
this._failedSequentialPings = 0;
|
||||
} else {
|
||||
// Attempt to retain a copy of the cached playerCount for up to N failed pings
|
||||
// This prevents minor connection issues from constantly reshuffling the layout
|
||||
if (++this._failedSequentialPings > 5) {
|
||||
this.playerCount = 0
|
||||
this.playerCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Use payload.playerCount so nulls WILL be pushed into the graphing data
|
||||
this._graphData[0].push(timestamp)
|
||||
this._graphData[1].push(payload.playerCount)
|
||||
this._graphData[0].push(timestamp);
|
||||
this._graphData[1].push(payload.playerCount);
|
||||
|
||||
// Trim graphData to within the max length by shifting out the leading elements
|
||||
for (const series of this._graphData) {
|
||||
if (series.length > this._app.publicConfig.serverGraphMaxLength) {
|
||||
series.shift()
|
||||
series.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw the plot instance
|
||||
this._plotInstance.setData(this._graphData)
|
||||
this._plotInstance.setData(this._graphData);
|
||||
}
|
||||
|
||||
updateServerRankIndex (rankIndex) {
|
||||
this.rankIndex = rankIndex
|
||||
updateServerRankIndex(rankIndex) {
|
||||
this.rankIndex = rankIndex;
|
||||
|
||||
document.getElementById(`ranking_${this.serverId}`).innerText = `#${rankIndex + 1}`
|
||||
document.getElementById(`ranking_${this.serverId}`).innerText = `#${
|
||||
rankIndex + 1
|
||||
}`;
|
||||
}
|
||||
|
||||
_renderValue (prefix, handler) {
|
||||
const labelElement = document.getElementById(`${prefix}_${this.serverId}`)
|
||||
_renderValue(prefix, handler) {
|
||||
const labelElement = document.getElementById(`${prefix}_${this.serverId}`);
|
||||
|
||||
labelElement.style.display = 'block'
|
||||
labelElement.style.display = "block";
|
||||
|
||||
const valueElement = document.getElementById(`${prefix}-value_${this.serverId}`)
|
||||
const targetElement = valueElement || labelElement
|
||||
const valueElement = document.getElementById(
|
||||
`${prefix}-value_${this.serverId}`
|
||||
);
|
||||
const targetElement = valueElement || labelElement;
|
||||
|
||||
if (targetElement) {
|
||||
if (typeof handler === 'function') {
|
||||
handler(targetElement)
|
||||
if (typeof handler === "function") {
|
||||
handler(targetElement);
|
||||
} else {
|
||||
targetElement.innerText = handler
|
||||
targetElement.innerText = handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hideValue (prefix) {
|
||||
const element = document.getElementById(`${prefix}_${this.serverId}`)
|
||||
_hideValue(prefix) {
|
||||
const element = document.getElementById(`${prefix}_${this.serverId}`);
|
||||
|
||||
element.style.display = 'none'
|
||||
element.style.display = "none";
|
||||
}
|
||||
|
||||
updateServerStatus (ping, minecraftVersions) {
|
||||
updateServerStatus(ping, minecraftVersions) {
|
||||
if (ping.versions) {
|
||||
this._renderValue('version', formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '')
|
||||
this._renderValue(
|
||||
"version",
|
||||
formatMinecraftVersions(
|
||||
ping.versions,
|
||||
minecraftVersions[this.data.type]
|
||||
) || ""
|
||||
);
|
||||
}
|
||||
|
||||
if (ping.recordData) {
|
||||
this._renderValue('record', (element) => {
|
||||
this._renderValue("record", (element) => {
|
||||
if (ping.recordData.timestamp > 0) {
|
||||
element.innerText = `${formatNumber(ping.recordData.playerCount)} (${formatDate(ping.recordData.timestamp)})`
|
||||
element.title = `At ${formatDate(ping.recordData.timestamp)} ${formatTimestampSeconds(ping.recordData.timestamp)}`
|
||||
element.innerText = `${formatNumber(
|
||||
ping.recordData.playerCount
|
||||
)} (${formatDate(ping.recordData.timestamp)})`;
|
||||
element.title = `At ${formatDate(
|
||||
ping.recordData.timestamp
|
||||
)} ${formatTimestampSeconds(ping.recordData.timestamp)}`;
|
||||
} else {
|
||||
element.innerText = formatNumber(ping.recordData.playerCount)
|
||||
element.innerText = formatNumber(ping.recordData.playerCount);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
this.lastRecordData = ping.recordData
|
||||
this.lastRecordData = ping.recordData;
|
||||
}
|
||||
|
||||
if (ping.graphPeakData) {
|
||||
this._renderValue('peak', (element) => {
|
||||
element.innerText = formatNumber(ping.graphPeakData.playerCount)
|
||||
element.title = `At ${formatTimestampSeconds(ping.graphPeakData.timestamp)}`
|
||||
})
|
||||
this._renderValue("peak", (element) => {
|
||||
element.innerText = formatNumber(ping.graphPeakData.playerCount);
|
||||
element.title = `At ${formatTimestampSeconds(
|
||||
ping.graphPeakData.timestamp
|
||||
)}`;
|
||||
});
|
||||
|
||||
this.lastPeakData = ping.graphPeakData
|
||||
this.lastPeakData = ping.graphPeakData;
|
||||
}
|
||||
|
||||
if (ping.error) {
|
||||
this._hideValue('player-count')
|
||||
this._renderValue('error', ping.error.message)
|
||||
} else if (typeof ping.playerCount !== 'number') {
|
||||
this._hideValue('player-count')
|
||||
this._hideValue("player-count");
|
||||
this._renderValue("error", ping.error.message);
|
||||
} else if (typeof ping.playerCount !== "number") {
|
||||
this._hideValue("player-count");
|
||||
|
||||
// If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object
|
||||
// In this case playerCount will safely be null, so provide a generic error message instead
|
||||
this._renderValue('error', 'Failed to ping')
|
||||
} else if (typeof ping.playerCount === 'number') {
|
||||
this._hideValue('error')
|
||||
this._renderValue('player-count', formatNumber(ping.playerCount))
|
||||
this._renderValue("error", "Failed to ping");
|
||||
} else if (typeof ping.playerCount === "number") {
|
||||
this._hideValue("error");
|
||||
this._renderValue("player-count", formatNumber(ping.playerCount));
|
||||
}
|
||||
|
||||
// An updated favicon has been sent, update the src
|
||||
if (ping.favicon) {
|
||||
const faviconElement = document.getElementById(`favicon_${this.serverId}`)
|
||||
const faviconElement = document.getElementById(
|
||||
`favicon_${this.serverId}`
|
||||
);
|
||||
|
||||
// Since favicons may be URLs, only update the attribute when it has changed
|
||||
// Otherwise the browser may send multiple requests to the same URL
|
||||
if (faviconElement.getAttribute('src') !== ping.favicon) {
|
||||
faviconElement.setAttribute('src', ping.favicon)
|
||||
if (faviconElement.getAttribute("src") !== ping.favicon) {
|
||||
faviconElement.setAttribute("src", ping.favicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initServerStatus (latestPing) {
|
||||
const serverElement = document.createElement('div')
|
||||
initServerStatus(latestPing) {
|
||||
const serverElement = document.createElement("div");
|
||||
|
||||
serverElement.id = `container_${this.serverId}`
|
||||
serverElement.id = `container_${this.serverId}`;
|
||||
serverElement.innerHTML = `<div class="column column-favicon">
|
||||
<img class="server-favicon" src="${latestPing.favicon || MISSING_FAVICON}" id="favicon_${this.serverId}" title="${this.data.name}\n${formatMinecraftServerAddress(this.data.ip, this.data.port)}">
|
||||
<img class="server-favicon" src="${
|
||||
latestPing.favicon || MISSING_FAVICON
|
||||
}" id="favicon_${this.serverId}" title="${
|
||||
this.data.name
|
||||
}\n${formatMinecraftServerAddress(this.data.ip, this.data.port)}">
|
||||
<span class="server-rank" id="ranking_${this.serverId}"></span>
|
||||
</div>
|
||||
<div class="column column-status">
|
||||
<h3 class="server-name"><span class="${this._app.favoritesManager.getIconClass(this.isFavorite)}" id="favorite-toggle_${this.serverId}"></span> ${this.data.name}</h3>
|
||||
<h3 class="server-name"><span class="${this._app.favoritesManager.getIconClass(
|
||||
this.isFavorite
|
||||
)}" id="favorite-toggle_${this.serverId}"></span> ${this.data.name}</h3>
|
||||
<span class="server-error" id="error_${this.serverId}"></span>
|
||||
<span class="server-label" id="player-count_${this.serverId}">Players: <span class="server-value" id="player-count-value_${this.serverId}"></span></span>
|
||||
<span class="server-label" id="peak_${this.serverId}">${this._app.publicConfig.graphDurationLabel} Peak: <span class="server-value" id="peak-value_${this.serverId}">-</span></span>
|
||||
<span class="server-label" id="record_${this.serverId}">Record: <span class="server-value" id="record-value_${this.serverId}">-</span></span>
|
||||
<span class="server-label" id="player-count_${
|
||||
this.serverId
|
||||
}">Players: <span class="server-value" id="player-count-value_${
|
||||
this.serverId
|
||||
}"></span></span>
|
||||
<span class="server-label" id="peak_${this.serverId}">${
|
||||
this._app.publicConfig.graphDurationLabel
|
||||
} Peak: <span class="server-value" id="peak-value_${
|
||||
this.serverId
|
||||
}">-</span></span>
|
||||
<span class="server-label" id="record_${
|
||||
this.serverId
|
||||
}">Record: <span class="server-value" id="record-value_${
|
||||
this.serverId
|
||||
}">-</span></span>
|
||||
<span class="server-label" id="version_${this.serverId}"></span>
|
||||
</div>
|
||||
<div class="column column-graph" id="chart_${this.serverId}"></div>`
|
||||
<div class="column column-graph" id="chart_${this.serverId}"></div>`;
|
||||
|
||||
serverElement.setAttribute('class', 'server')
|
||||
serverElement.setAttribute("class", "server");
|
||||
|
||||
document.getElementById('server-list').appendChild(serverElement)
|
||||
document.getElementById("server-list").appendChild(serverElement);
|
||||
}
|
||||
|
||||
updateHighlightedValue (selectedCategory) {
|
||||
['player-count', 'peak', 'record'].forEach((category) => {
|
||||
const labelElement = document.getElementById(`${category}_${this.serverId}`)
|
||||
const valueElement = document.getElementById(`${category}-value_${this.serverId}`)
|
||||
updateHighlightedValue(selectedCategory) {
|
||||
["player-count", "peak", "record"].forEach((category) => {
|
||||
const labelElement = document.getElementById(
|
||||
`${category}_${this.serverId}`
|
||||
);
|
||||
const valueElement = document.getElementById(
|
||||
`${category}-value_${this.serverId}`
|
||||
);
|
||||
|
||||
if (selectedCategory && category === selectedCategory) {
|
||||
labelElement.setAttribute('class', 'server-highlighted-label')
|
||||
valueElement.setAttribute('class', 'server-highlighted-value')
|
||||
labelElement.setAttribute("class", "server-highlighted-label");
|
||||
valueElement.setAttribute("class", "server-highlighted-value");
|
||||
} else {
|
||||
labelElement.setAttribute('class', 'server-label')
|
||||
valueElement.setAttribute('class', 'server-value')
|
||||
labelElement.setAttribute("class", "server-label");
|
||||
valueElement.setAttribute("class", "server-value");
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
initEventListeners () {
|
||||
document.getElementById(`favorite-toggle_${this.serverId}`).addEventListener('click', () => {
|
||||
this._app.favoritesManager.handleFavoriteButtonClick(this)
|
||||
}, false)
|
||||
initEventListeners() {
|
||||
document
|
||||
.getElementById(`favorite-toggle_${this.serverId}`)
|
||||
.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
this._app.favoritesManager.handleFavoriteButtonClick(this);
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,176 +1,205 @@
|
||||
export class SocketManager {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._hasRequestedHistoryGraph = false
|
||||
this._reconnectDelayBase = 0
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._hasRequestedHistoryGraph = false;
|
||||
this._reconnectDelayBase = 0;
|
||||
}
|
||||
|
||||
reset () {
|
||||
this._hasRequestedHistoryGraph = false
|
||||
reset() {
|
||||
this._hasRequestedHistoryGraph = false;
|
||||
}
|
||||
|
||||
createWebSocket () {
|
||||
let webSocketProtocol = 'ws:'
|
||||
if (location.protocol === 'https:') {
|
||||
webSocketProtocol = 'wss:'
|
||||
createWebSocket() {
|
||||
let webSocketProtocol = "ws:";
|
||||
if (location.protocol === "https:") {
|
||||
webSocketProtocol = "wss:";
|
||||
}
|
||||
|
||||
this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`)
|
||||
this._webSocket = new WebSocket(`${webSocketProtocol}//${location.host}`);
|
||||
|
||||
// The backend will automatically push data once connected
|
||||
this._webSocket.onopen = () => {
|
||||
this._app.caption.set('Loading...')
|
||||
this._app.caption.set("Loading...");
|
||||
|
||||
// Reset reconnection scheduling since the WebSocket has been established
|
||||
this._reconnectDelayBase = 0
|
||||
}
|
||||
this._reconnectDelayBase = 0;
|
||||
};
|
||||
|
||||
this._webSocket.onclose = (event) => {
|
||||
this._app.handleDisconnect()
|
||||
this._app.handleDisconnect();
|
||||
|
||||
// Modify page state to display loading overlay
|
||||
// Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
|
||||
// Treat other codes as active errors (besides connectivity errors) when displaying the message
|
||||
if (event.code === 1006) {
|
||||
this._app.caption.set('Lost connection!')
|
||||
this._app.caption.set("Lost connection!");
|
||||
} else {
|
||||
this._app.caption.set('Disconnected due to error.')
|
||||
this._app.caption.set("Disconnected due to error.");
|
||||
}
|
||||
|
||||
// Schedule socket reconnection attempt
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this._webSocket.onmessage = (message) => {
|
||||
const payload = JSON.parse(message.data)
|
||||
const payload = JSON.parse(message.data);
|
||||
|
||||
switch (payload.message) {
|
||||
case 'init':
|
||||
this._app.setPublicConfig(payload.config)
|
||||
case "init":
|
||||
this._app.setPublicConfig(payload.config);
|
||||
|
||||
// Display the main page component
|
||||
// Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn
|
||||
this._app.setPageReady(true)
|
||||
this._app.setPageReady(true);
|
||||
|
||||
// Allow the graphDisplayManager to control whether or not the historical graph is loaded
|
||||
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
|
||||
if (this._app.publicConfig.isGraphVisible) {
|
||||
this.sendHistoryGraphRequest()
|
||||
this.sendHistoryGraphRequest();
|
||||
}
|
||||
|
||||
payload.servers.forEach((serverPayload, serverId) => {
|
||||
this._app.addServer(serverId, serverPayload, payload.timestampPoints)
|
||||
})
|
||||
this._app.addServer(
|
||||
serverId,
|
||||
serverPayload,
|
||||
payload.timestampPoints
|
||||
);
|
||||
});
|
||||
|
||||
// Init payload contains all data needed to render the page
|
||||
// Alert the app it is ready
|
||||
this._app.handleSyncComplete()
|
||||
this._app.handleSyncComplete();
|
||||
|
||||
break
|
||||
break;
|
||||
|
||||
case 'updateServers': {
|
||||
for (let serverId = 0; serverId < payload.updates.length; serverId++) {
|
||||
case "updateServers": {
|
||||
for (
|
||||
let serverId = 0;
|
||||
serverId < payload.updates.length;
|
||||
serverId++
|
||||
) {
|
||||
// The backend may send "update" events prior to receiving all "add" events
|
||||
// A server has only been added once it's ServerRegistration is defined
|
||||
// Checking undefined protects from this race condition
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
|
||||
const serverUpdate = payload.updates[serverId]
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverId);
|
||||
const serverUpdate = payload.updates[serverId];
|
||||
|
||||
if (serverRegistration) {
|
||||
serverRegistration.handlePing(serverUpdate, payload.timestamp)
|
||||
serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions)
|
||||
serverRegistration.handlePing(serverUpdate, payload.timestamp);
|
||||
serverRegistration.updateServerStatus(
|
||||
serverUpdate,
|
||||
this._app.publicConfig.minecraftVersions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk add playerCounts into graph during #updateHistoryGraph
|
||||
if (payload.updateHistoryGraph) {
|
||||
this._app.graphDisplayManager.addGraphPoint(payload.timestamp, Object.values(payload.updates).map(update => update.playerCount))
|
||||
this._app.graphDisplayManager.addGraphPoint(
|
||||
payload.timestamp,
|
||||
Object.values(payload.updates).map((update) => update.playerCount)
|
||||
);
|
||||
|
||||
// Run redraw tasks after handling bulk updates
|
||||
this._app.graphDisplayManager.redraw()
|
||||
this._app.graphDisplayManager.redraw();
|
||||
}
|
||||
|
||||
this._app.percentageBar.redraw()
|
||||
this._app.updateGlobalStats()
|
||||
this._app.percentageBar.redraw();
|
||||
this._app.updateGlobalStats();
|
||||
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
case 'historyGraph': {
|
||||
this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData)
|
||||
case "historyGraph": {
|
||||
this._app.graphDisplayManager.buildPlotInstance(
|
||||
payload.timestamps,
|
||||
payload.graphData
|
||||
);
|
||||
|
||||
// Build checkbox elements for graph controls
|
||||
let lastRowCounter = 0
|
||||
let controlsHTML = ''
|
||||
let lastRowCounter = 0;
|
||||
let controlsHTML = "";
|
||||
|
||||
this._app.serverRegistry.getServerRegistrations()
|
||||
.map(serverRegistration => serverRegistration.data.name)
|
||||
this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.map((serverRegistration) => serverRegistration.data.name)
|
||||
.sort()
|
||||
.forEach(serverName => {
|
||||
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName)
|
||||
.forEach((serverName) => {
|
||||
const serverRegistration =
|
||||
this._app.serverRegistry.getServerRegistration(serverName);
|
||||
|
||||
controlsHTML += `<td><label>
|
||||
<input type="checkbox" class="graph-control" minetrack-server-id="${serverRegistration.serverId}" ${serverRegistration.isVisible ? 'checked' : ''}>
|
||||
<input type="checkbox" class="graph-control" minetrack-server-id="${
|
||||
serverRegistration.serverId
|
||||
}" ${serverRegistration.isVisible ? "checked" : ""}>
|
||||
${serverName}
|
||||
</label></td>`
|
||||
</label></td>`;
|
||||
|
||||
// Occasionally break table rows using a magic number
|
||||
if (++lastRowCounter % 6 === 0) {
|
||||
controlsHTML += '</tr><tr>'
|
||||
controlsHTML += "</tr><tr>";
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Apply generated HTML and show controls
|
||||
document.getElementById('big-graph-checkboxes').innerHTML = `<table><tr>${controlsHTML}</tr></table>`
|
||||
document.getElementById('big-graph-controls').style.display = 'block'
|
||||
document.getElementById(
|
||||
"big-graph-checkboxes"
|
||||
).innerHTML = `<table><tr>${controlsHTML}</tr></table>`;
|
||||
document.getElementById("big-graph-controls").style.display = "block";
|
||||
|
||||
// Bind click event for updating graph data
|
||||
this._app.graphDisplayManager.initEventListeners()
|
||||
break
|
||||
}
|
||||
this._app.graphDisplayManager.initEventListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scheduleReconnect () {
|
||||
scheduleReconnect() {
|
||||
// Release any active WebSocket references
|
||||
this._webSocket = undefined
|
||||
this._webSocket = undefined;
|
||||
|
||||
this._reconnectDelayBase++
|
||||
this._reconnectDelayBase++;
|
||||
|
||||
// Exponential backoff for reconnection attempts
|
||||
// Clamp ceiling value to 30 seconds
|
||||
this._reconnectDelaySeconds = Math.min((this._reconnectDelayBase * this._reconnectDelayBase), 30)
|
||||
this._reconnectDelaySeconds = Math.min(
|
||||
this._reconnectDelayBase * this._reconnectDelayBase,
|
||||
30
|
||||
);
|
||||
|
||||
const reconnectInterval = setInterval(() => {
|
||||
this._reconnectDelaySeconds--
|
||||
this._reconnectDelaySeconds--;
|
||||
|
||||
if (this._reconnectDelaySeconds === 0) {
|
||||
// Explicitly clear interval, this avoids race conditions
|
||||
// #clearInterval first to avoid potential errors causing pre-mature returns
|
||||
clearInterval(reconnectInterval)
|
||||
clearInterval(reconnectInterval);
|
||||
|
||||
// Update displayed text
|
||||
this._app.caption.set('Reconnecting...')
|
||||
this._app.caption.set("Reconnecting...");
|
||||
|
||||
// Attempt reconnection
|
||||
// Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started
|
||||
this.createWebSocket()
|
||||
this.createWebSocket();
|
||||
} else if (this._reconnectDelaySeconds > 0) {
|
||||
// Update displayed text
|
||||
this._app.caption.set(`Reconnecting in ${this._reconnectDelaySeconds}s...`)
|
||||
this._app.caption.set(
|
||||
`Reconnecting in ${this._reconnectDelaySeconds}s...`
|
||||
);
|
||||
}
|
||||
}, 1000)
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
sendHistoryGraphRequest () {
|
||||
sendHistoryGraphRequest() {
|
||||
if (!this._hasRequestedHistoryGraph) {
|
||||
this._hasRequestedHistoryGraph = true
|
||||
this._hasRequestedHistoryGraph = true;
|
||||
|
||||
// Send request as a plain text string to avoid the server needing to parse JSON
|
||||
// This is mostly to simplify the backend server's need for error handling
|
||||
this._webSocket.send('requestHistoryGraph')
|
||||
this._webSocket.send("requestHistoryGraph");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,199 +1,215 @@
|
||||
const SORT_OPTIONS = [
|
||||
{
|
||||
getName: () => 'Players',
|
||||
getName: () => "Players",
|
||||
sortFunc: (a, b) => b.playerCount - a.playerCount,
|
||||
highlightedValue: 'player-count'
|
||||
highlightedValue: "player-count",
|
||||
},
|
||||
{
|
||||
getName: (app) => {
|
||||
return `${app.publicConfig.graphDurationLabel} Peak`
|
||||
return `${app.publicConfig.graphDurationLabel} Peak`;
|
||||
},
|
||||
sortFunc: (a, b) => {
|
||||
if (!a.lastPeakData && !b.lastPeakData) {
|
||||
return 0
|
||||
return 0;
|
||||
} else if (a.lastPeakData && !b.lastPeakData) {
|
||||
return -1
|
||||
return -1;
|
||||
} else if (b.lastPeakData && !a.lastPeakData) {
|
||||
return 1
|
||||
return 1;
|
||||
}
|
||||
return b.lastPeakData.playerCount - a.lastPeakData.playerCount
|
||||
return b.lastPeakData.playerCount - a.lastPeakData.playerCount;
|
||||
},
|
||||
testFunc: (app) => {
|
||||
// Require at least one ServerRegistration to have a lastPeakData value defined
|
||||
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
|
||||
if (serverRegistration.lastPeakData) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
highlightedValue: 'peak'
|
||||
highlightedValue: "peak",
|
||||
},
|
||||
{
|
||||
getName: () => 'Record',
|
||||
getName: () => "Record",
|
||||
sortFunc: (a, b) => {
|
||||
if (!a.lastRecordData && !b.lastRecordData) {
|
||||
return 0
|
||||
return 0;
|
||||
} else if (a.lastRecordData && !b.lastRecordData) {
|
||||
return -1
|
||||
return -1;
|
||||
} else if (b.lastRecordData && !a.lastRecordData) {
|
||||
return 1
|
||||
return 1;
|
||||
}
|
||||
return b.lastRecordData.playerCount - a.lastRecordData.playerCount
|
||||
return b.lastRecordData.playerCount - a.lastRecordData.playerCount;
|
||||
},
|
||||
testFunc: (app) => {
|
||||
// Require at least one ServerRegistration to have a lastRecordData value defined
|
||||
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
|
||||
if (serverRegistration.lastRecordData) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
highlightedValue: 'record'
|
||||
}
|
||||
]
|
||||
highlightedValue: "record",
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_OPTION_INDEX_DEFAULT = 0
|
||||
const SORT_OPTION_INDEX_STORAGE_KEY = 'minetrack_sort_option_index'
|
||||
const SORT_OPTION_INDEX_DEFAULT = 0;
|
||||
const SORT_OPTION_INDEX_STORAGE_KEY = "minetrack_sort_option_index";
|
||||
|
||||
export class SortController {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._buttonElement = document.getElementById('sort-by')
|
||||
this._textElement = document.getElementById('sort-by-text')
|
||||
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._buttonElement = document.getElementById("sort-by");
|
||||
this._textElement = document.getElementById("sort-by-text");
|
||||
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT;
|
||||
}
|
||||
|
||||
reset () {
|
||||
this._lastSortedServers = undefined
|
||||
reset() {
|
||||
this._lastSortedServers = undefined;
|
||||
|
||||
// Reset modified DOM structures
|
||||
this._buttonElement.style.display = 'none'
|
||||
this._textElement.innerText = '...'
|
||||
this._buttonElement.style.display = "none";
|
||||
this._textElement.innerText = "...";
|
||||
|
||||
// Remove bound DOM event listeners
|
||||
this._buttonElement.removeEventListener('click', this.handleSortByButtonClick)
|
||||
this._buttonElement.removeEventListener(
|
||||
"click",
|
||||
this.handleSortByButtonClick
|
||||
);
|
||||
}
|
||||
|
||||
loadLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const sortOptionIndex = localStorage.getItem(SORT_OPTION_INDEX_STORAGE_KEY)
|
||||
loadLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const sortOptionIndex = localStorage.getItem(
|
||||
SORT_OPTION_INDEX_STORAGE_KEY
|
||||
);
|
||||
if (sortOptionIndex) {
|
||||
this._sortOptionIndex = parseInt(sortOptionIndex)
|
||||
this._sortOptionIndex = parseInt(sortOptionIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalStorage () {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
updateLocalStorage() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
if (this._sortOptionIndex !== SORT_OPTION_INDEX_DEFAULT) {
|
||||
localStorage.setItem(SORT_OPTION_INDEX_STORAGE_KEY, this._sortOptionIndex)
|
||||
localStorage.setItem(
|
||||
SORT_OPTION_INDEX_STORAGE_KEY,
|
||||
this._sortOptionIndex
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY)
|
||||
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show () {
|
||||
show() {
|
||||
// Load the saved option selection, if any
|
||||
this.loadLocalStorage()
|
||||
this.loadLocalStorage();
|
||||
|
||||
this.updateSortOption()
|
||||
this.updateSortOption();
|
||||
|
||||
// Bind DOM event listeners
|
||||
// This is removed by #reset to avoid multiple listeners
|
||||
this._buttonElement.addEventListener('click', this.handleSortByButtonClick)
|
||||
this._buttonElement.addEventListener("click", this.handleSortByButtonClick);
|
||||
|
||||
// Show #sort-by element
|
||||
this._buttonElement.style.display = 'inline-block'
|
||||
this._buttonElement.style.display = "inline-block";
|
||||
}
|
||||
|
||||
handleSortByButtonClick = () => {
|
||||
while (true) {
|
||||
// Increment to the next sort option, wrap around if needed
|
||||
this._sortOptionIndex = (this._sortOptionIndex + 1) % SORT_OPTIONS.length
|
||||
this._sortOptionIndex = (this._sortOptionIndex + 1) % SORT_OPTIONS.length;
|
||||
|
||||
// Only break if the sortOption is supported
|
||||
// This can technically cause an infinite loop, but never should assuming
|
||||
// at least one sortOption does not implement the test OR always returns true
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex];
|
||||
|
||||
if (!sortOption.testFunc || sortOption.testFunc(this._app)) {
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw the button and sort the servers
|
||||
this.updateSortOption()
|
||||
this.updateSortOption();
|
||||
|
||||
// Save the updated option selection
|
||||
this.updateLocalStorage()
|
||||
}
|
||||
this.updateLocalStorage();
|
||||
};
|
||||
|
||||
updateSortOption = () => {
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex];
|
||||
|
||||
// Pass app instance so sortOption names can be dynamically generated
|
||||
this._textElement.innerText = sortOption.getName(this._app)
|
||||
this._textElement.innerText = sortOption.getName(this._app);
|
||||
|
||||
// Update all servers highlighted values
|
||||
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
|
||||
serverRegistration.updateHighlightedValue(sortOption.highlightedValue)
|
||||
serverRegistration.updateHighlightedValue(sortOption.highlightedValue);
|
||||
}
|
||||
|
||||
this.sortServers()
|
||||
}
|
||||
this.sortServers();
|
||||
};
|
||||
|
||||
sortServers = () => {
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
|
||||
const sortOption = SORT_OPTIONS[this._sortOptionIndex];
|
||||
|
||||
const sortedServers = this._app.serverRegistry.getServerRegistrations().sort((a, b) => {
|
||||
const sortedServers = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) {
|
||||
return -1
|
||||
return -1;
|
||||
} else if (b.isFavorite && !a.isFavorite) {
|
||||
return 1
|
||||
return 1;
|
||||
}
|
||||
|
||||
return sortOption.sortFunc(a, b)
|
||||
})
|
||||
return sortOption.sortFunc(a, b);
|
||||
});
|
||||
|
||||
// Test if sortedServers has changed from the previous listing
|
||||
// This avoids DOM updates and graphs being redrawn
|
||||
const sortedServerIds = sortedServers.map(server => server.serverId)
|
||||
const sortedServerIds = sortedServers.map((server) => server.serverId);
|
||||
|
||||
if (this._lastSortedServers) {
|
||||
let allMatch = true
|
||||
let allMatch = true;
|
||||
|
||||
// Test if the arrays have actually changed
|
||||
// No need to length check, they are the same source data each time
|
||||
for (let i = 0; i < sortedServerIds.length; i++) {
|
||||
if (sortedServerIds[i] !== this._lastSortedServers[i]) {
|
||||
allMatch = false
|
||||
break
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._lastSortedServers = sortedServerIds
|
||||
this._lastSortedServers = sortedServerIds;
|
||||
|
||||
// Sort a ServerRegistration list by the sortOption ONLY
|
||||
// This is used to determine the ServerRegistration's rankIndex without #isFavorite skewing values
|
||||
const rankIndexSort = this._app.serverRegistry.getServerRegistrations().sort(sortOption.sortFunc)
|
||||
const rankIndexSort = this._app.serverRegistry
|
||||
.getServerRegistrations()
|
||||
.sort(sortOption.sortFunc);
|
||||
|
||||
// Update the DOM structure
|
||||
sortedServers.forEach(function (serverRegistration) {
|
||||
const parentElement = document.getElementById('server-list')
|
||||
const serverElement = document.getElementById(`container_${serverRegistration.serverId}`)
|
||||
const parentElement = document.getElementById("server-list");
|
||||
const serverElement = document.getElementById(
|
||||
`container_${serverRegistration.serverId}`
|
||||
);
|
||||
|
||||
parentElement.appendChild(serverElement)
|
||||
parentElement.appendChild(serverElement);
|
||||
|
||||
// Set the ServerRegistration's rankIndex to its indexOf the normal sort
|
||||
serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration))
|
||||
})
|
||||
}
|
||||
serverRegistration.updateServerRankIndex(
|
||||
rankIndexSort.indexOf(serverRegistration)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -1,124 +1,131 @@
|
||||
export class Tooltip {
|
||||
constructor () {
|
||||
this._div = document.getElementById('tooltip')
|
||||
constructor() {
|
||||
this._div = document.getElementById("tooltip");
|
||||
}
|
||||
|
||||
set (x, y, offsetX, offsetY, html) {
|
||||
this._div.innerHTML = html
|
||||
set(x, y, offsetX, offsetY, html) {
|
||||
this._div.innerHTML = html;
|
||||
|
||||
// Assign display: block so that the offsetWidth is valid
|
||||
this._div.style.display = 'block'
|
||||
this._div.style.display = "block";
|
||||
|
||||
// Prevent the div from overflowing the page width
|
||||
const tooltipWidth = this._div.offsetWidth
|
||||
const tooltipWidth = this._div.offsetWidth;
|
||||
|
||||
// 1.2 is a magic number used to pad the offset to ensure the tooltip
|
||||
// never gets close or surpasses the page's X width
|
||||
if (x + offsetX + (tooltipWidth * 1.2) > window.innerWidth) {
|
||||
x -= tooltipWidth
|
||||
offsetX *= -1
|
||||
if (x + offsetX + tooltipWidth * 1.2 > window.innerWidth) {
|
||||
x -= tooltipWidth;
|
||||
offsetX *= -1;
|
||||
}
|
||||
|
||||
this._div.style.top = `${y + offsetY}px`
|
||||
this._div.style.left = `${x + offsetX}px`
|
||||
this._div.style.top = `${y + offsetY}px`;
|
||||
this._div.style.left = `${x + offsetX}px`;
|
||||
}
|
||||
|
||||
hide = () => {
|
||||
this._div.style.display = 'none'
|
||||
}
|
||||
this._div.style.display = "none";
|
||||
};
|
||||
}
|
||||
|
||||
export class Caption {
|
||||
constructor () {
|
||||
this._div = document.getElementById('status-text')
|
||||
constructor() {
|
||||
this._div = document.getElementById("status-text");
|
||||
}
|
||||
|
||||
set (text) {
|
||||
this._div.innerText = text
|
||||
this._div.style.display = 'block'
|
||||
set(text) {
|
||||
this._div.innerText = text;
|
||||
this._div.style.display = "block";
|
||||
}
|
||||
|
||||
hide () {
|
||||
this._div.style.display = 'none'
|
||||
hide() {
|
||||
this._div.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Minecraft Java Edition default server port: 25565
|
||||
// Minecraft Bedrock Edition default server port: 19132
|
||||
const MINECRAFT_DEFAULT_PORTS = [25565, 19132]
|
||||
const MINECRAFT_DEFAULT_PORTS = [25565, 19132];
|
||||
|
||||
export function formatMinecraftServerAddress (ip, port) {
|
||||
export function formatMinecraftServerAddress(ip, port) {
|
||||
if (port && !MINECRAFT_DEFAULT_PORTS.includes(port)) {
|
||||
return `${ip}:${port}`
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
return ip
|
||||
return ip;
|
||||
}
|
||||
|
||||
// Detect gaps in versions by matching their indexes to knownVersions
|
||||
export function formatMinecraftVersions (versions, knownVersions) {
|
||||
if (!versions || !versions.length || !knownVersions || !knownVersions.length) {
|
||||
return
|
||||
export function formatMinecraftVersions(versions, knownVersions) {
|
||||
if (
|
||||
!versions ||
|
||||
!versions.length ||
|
||||
!knownVersions ||
|
||||
!knownVersions.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentVersionGroup = []
|
||||
const versionGroups = []
|
||||
let currentVersionGroup = [];
|
||||
const versionGroups = [];
|
||||
|
||||
for (let i = 0; i < versions.length; i++) {
|
||||
// Look for value mismatch between the previous index
|
||||
// Require i > 0 since lastVersionIndex is undefined for i === 0
|
||||
if (i > 0 && versions[i] - versions[i - 1] !== 1) {
|
||||
versionGroups.push(currentVersionGroup)
|
||||
currentVersionGroup = []
|
||||
versionGroups.push(currentVersionGroup);
|
||||
currentVersionGroup = [];
|
||||
}
|
||||
|
||||
currentVersionGroup.push(versions[i])
|
||||
currentVersionGroup.push(versions[i]);
|
||||
}
|
||||
|
||||
// Ensure the last versionGroup is always pushed
|
||||
if (currentVersionGroup.length > 0) {
|
||||
versionGroups.push(currentVersionGroup)
|
||||
versionGroups.push(currentVersionGroup);
|
||||
}
|
||||
|
||||
if (versionGroups.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Remap individual versionGroups values into named versions
|
||||
return versionGroups.map(versionGroup => {
|
||||
const startVersion = knownVersions[versionGroup[0]]
|
||||
return versionGroups
|
||||
.map((versionGroup) => {
|
||||
const startVersion = knownVersions[versionGroup[0]];
|
||||
|
||||
if (versionGroup.length === 1) {
|
||||
// A versionGroup may contain a single version, only return its name
|
||||
// This is a cosmetic catch to avoid version labels like 1.0-1.0
|
||||
return startVersion
|
||||
return startVersion;
|
||||
} else {
|
||||
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]]
|
||||
return `${startVersion}-${endVersion}`
|
||||
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]];
|
||||
return `${startVersion}-${endVersion}`;
|
||||
}
|
||||
}).join(', ')
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function formatTimestampSeconds (secs) {
|
||||
const date = new Date(0)
|
||||
date.setUTCSeconds(secs)
|
||||
return date.toLocaleTimeString()
|
||||
export function formatTimestampSeconds(secs) {
|
||||
const date = new Date(0);
|
||||
date.setUTCSeconds(secs);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
export function formatDate (secs) {
|
||||
const date = new Date(0)
|
||||
date.setUTCSeconds(secs)
|
||||
return date.toLocaleDateString()
|
||||
export function formatDate(secs) {
|
||||
const date = new Date(0);
|
||||
date.setUTCSeconds(secs);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function formatPercent (x, over) {
|
||||
const val = Math.round((x / over) * 100 * 10) / 10
|
||||
return `${val}%`
|
||||
export function formatPercent(x, over) {
|
||||
const val = Math.round((x / over) * 100 * 10) / 10;
|
||||
return `${val}%`;
|
||||
}
|
||||
|
||||
export function formatNumber (x) {
|
||||
if (typeof x !== 'number') {
|
||||
return '-'
|
||||
export function formatNumber(x) {
|
||||
if (typeof x !== "number") {
|
||||
return "-";
|
||||
} else {
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,8 @@
|
||||
"ip": "0.0.0.0"
|
||||
},
|
||||
"rates": {
|
||||
"pingAll": 15000,
|
||||
"connectTimeout": 2500
|
||||
"pingAll": 30000,
|
||||
"connectTimeout": 5000
|
||||
},
|
||||
"oldPingsCleanup": {
|
||||
"enabled": false,
|
||||
@ -13,6 +13,6 @@
|
||||
},
|
||||
"logFailedPings": true,
|
||||
"logToDatabase": true,
|
||||
"graphDuration": 86400000,
|
||||
"serverGraphDuration": 180000
|
||||
"graphDuration": 604800000,
|
||||
"serverGraphDuration": 360000
|
||||
}
|
||||
|
@ -1,12 +1,20 @@
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
minetrack:
|
||||
build: .
|
||||
image: fascinated/minetrack:latest
|
||||
# or
|
||||
# build: https://git.fascinated.cc/Fascinated/Minetrack.git
|
||||
container_name: minetrack
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
ports:
|
||||
- "80:8080"
|
||||
- "8880: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
|
||||
|
97
lib/app.js
97
lib/app.js
@ -1,87 +1,102 @@
|
||||
const Database = require('./database')
|
||||
const PingController = require('./ping')
|
||||
const Server = require('./server')
|
||||
const { TimeTracker } = require('./time')
|
||||
const MessageOf = require('./message')
|
||||
const Database = require("./database");
|
||||
const PingController = require("./ping");
|
||||
const Server = require("./server");
|
||||
const { TimeTracker } = require("./time");
|
||||
const MessageOf = require("./message");
|
||||
|
||||
const config = require('../config')
|
||||
const minecraftVersions = require('../minecraft_versions')
|
||||
const config = require("../config");
|
||||
const minecraftVersions = require("../minecraft_versions");
|
||||
const { formatMsToTime } = require("./utils/timeUtils");
|
||||
|
||||
class App {
|
||||
serverRegistrations = []
|
||||
serverRegistrations = [];
|
||||
|
||||
constructor () {
|
||||
this.pingController = new PingController(this)
|
||||
this.server = new Server(this)
|
||||
this.timeTracker = new TimeTracker(this)
|
||||
constructor() {
|
||||
this.pingController = new PingController(this);
|
||||
this.server = new Server(this);
|
||||
this.timeTracker = new TimeTracker(this);
|
||||
}
|
||||
|
||||
loadDatabase (callback) {
|
||||
this.database = new Database(this)
|
||||
loadDatabase(callback) {
|
||||
this.database = new Database(this);
|
||||
|
||||
// Setup database instance
|
||||
this.database.ensureIndexes(() => {
|
||||
this.database.loadGraphPoints(config.graphDuration, () => {
|
||||
this.database.loadRecords(() => {
|
||||
if (config.oldPingsCleanup && config.oldPingsCleanup.enabled) {
|
||||
this.database.initOldPingsDelete(callback)
|
||||
this.database.initOldPingsDelete(callback);
|
||||
} else {
|
||||
callback()
|
||||
callback();
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleReady () {
|
||||
this.server.listen(config.site.ip, config.site.port)
|
||||
handleReady() {
|
||||
this.server.listen(config.site.ip, config.site.port);
|
||||
|
||||
// Allow individual modules to manage their own task scheduling
|
||||
this.pingController.schedule()
|
||||
this.pingController.schedule();
|
||||
}
|
||||
|
||||
handleClientConnection = (client) => {
|
||||
if (config.logToDatabase) {
|
||||
client.on('message', (message) => {
|
||||
if (message === 'requestHistoryGraph') {
|
||||
client.on("message", (message) => {
|
||||
if (message === "requestHistoryGraph") {
|
||||
// Send historical graphData built from all serverRegistrations
|
||||
const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData)
|
||||
const graphData = this.serverRegistrations.map(
|
||||
(serverRegistration) => serverRegistration.graphData
|
||||
);
|
||||
|
||||
// Send graphData in object wrapper to avoid needing to explicity filter
|
||||
// any header data being appended by #MessageOf since the graph data is fed
|
||||
// directly into the graphing system
|
||||
client.send(MessageOf('historyGraph', {
|
||||
client.send(
|
||||
MessageOf("historyGraph", {
|
||||
timestamps: this.timeTracker.getGraphPoints(),
|
||||
graphData
|
||||
}))
|
||||
}
|
||||
graphData,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const initMessage = {
|
||||
config: (() => {
|
||||
// Remap minecraftVersion entries into name values
|
||||
const minecraftVersionNames = {}
|
||||
const minecraftVersionNames = {};
|
||||
Object.keys(minecraftVersions).forEach(function (key) {
|
||||
minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name)
|
||||
})
|
||||
minecraftVersionNames[key] = minecraftVersions[key].map(
|
||||
(version) => version.name
|
||||
);
|
||||
});
|
||||
|
||||
// Send configuration data for rendering the page
|
||||
return {
|
||||
graphDurationLabel: config.graphDurationLabel || (Math.floor(config.graphDuration / (60 * 60 * 1000)) + 'h'),
|
||||
// graphDurationLabel:
|
||||
// config.graphDurationLabel ||
|
||||
// Math.floor(config.graphDuration / (60 * 60 * 1000)) + "h",
|
||||
graphDurationLabel:
|
||||
config.graphDurationLabel || formatMsToTime(config.graphDuration),
|
||||
graphMaxLength: TimeTracker.getMaxGraphDataLength(),
|
||||
serverGraphMaxLength: TimeTracker.getMaxServerGraphDataLength(),
|
||||
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPublicData()),
|
||||
servers: this.serverRegistrations.map((serverRegistration) =>
|
||||
serverRegistration.getPublicData()
|
||||
),
|
||||
minecraftVersions: minecraftVersionNames,
|
||||
isGraphVisible: config.logToDatabase
|
||||
}
|
||||
isGraphVisible: config.logToDatabase,
|
||||
};
|
||||
})(),
|
||||
timestampPoints: this.timeTracker.getServerGraphPoints(),
|
||||
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory())
|
||||
}
|
||||
servers: this.serverRegistrations.map((serverRegistration) =>
|
||||
serverRegistration.getPingHistory()
|
||||
),
|
||||
};
|
||||
|
||||
client.send(MessageOf('init', initMessage))
|
||||
}
|
||||
client.send(MessageOf("init", initMessage));
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = App
|
||||
module.exports = App;
|
||||
|
352
lib/database.js
352
lib/database.js
@ -1,308 +1,360 @@
|
||||
const sqlite = require('sqlite3')
|
||||
const sqlite = require("sqlite3");
|
||||
|
||||
const logger = require('./logger')
|
||||
const logger = require("./logger");
|
||||
|
||||
const config = require('../config')
|
||||
const { TimeTracker } = require('./time')
|
||||
const dataFolder = 'data/';
|
||||
const config = require("../config");
|
||||
const { TimeTracker } = require("./time");
|
||||
const dataFolder = "data/";
|
||||
|
||||
class Database {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._sql = new sqlite.Database(dataFolder + 'database.sql')
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._sql = new sqlite.Database(dataFolder + "database.sql");
|
||||
}
|
||||
|
||||
getDailyDatabase () {
|
||||
getDailyDatabase() {
|
||||
if (!config.createDailyDatabaseCopy) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date()
|
||||
const fileName = `database_copy_${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}.sql`
|
||||
const date = new Date();
|
||||
const fileName = `database_copy_${date.getDate()}-${
|
||||
date.getMonth() + 1
|
||||
}-${date.getFullYear()}.sql`;
|
||||
|
||||
if (fileName !== this._currentDatabaseCopyFileName) {
|
||||
if (this._currentDatabaseCopyInstance) {
|
||||
this._currentDatabaseCopyInstance.close()
|
||||
this._currentDatabaseCopyInstance.close();
|
||||
}
|
||||
|
||||
this._currentDatabaseCopyInstance = new sqlite.Database(dataFolder + fileName)
|
||||
this._currentDatabaseCopyFileName = fileName
|
||||
this._currentDatabaseCopyInstance = new sqlite.Database(
|
||||
dataFolder + fileName
|
||||
);
|
||||
this._currentDatabaseCopyFileName = fileName;
|
||||
|
||||
// Ensure the initial tables are created
|
||||
// This does not created indexes since it is only inserted to
|
||||
this._currentDatabaseCopyInstance.serialize(() => {
|
||||
this._currentDatabaseCopyInstance.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)', err => {
|
||||
this._currentDatabaseCopyInstance.run(
|
||||
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
|
||||
(err) => {
|
||||
if (err) {
|
||||
logger.log('error', 'Cannot create initial table for daily database')
|
||||
throw err
|
||||
logger.log(
|
||||
"error",
|
||||
"Cannot create initial table for daily database"
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return this._currentDatabaseCopyInstance
|
||||
return this._currentDatabaseCopyInstance;
|
||||
}
|
||||
|
||||
ensureIndexes (callback) {
|
||||
const handleError = err => {
|
||||
ensureIndexes(callback) {
|
||||
const handleError = (err) => {
|
||||
if (err) {
|
||||
logger.log('error', 'Cannot create table or table index')
|
||||
throw err
|
||||
}
|
||||
logger.log("error", "Cannot create table or table index");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
this._sql.serialize(() => {
|
||||
this._sql.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)', handleError)
|
||||
this._sql.run('CREATE TABLE IF NOT EXISTS players_record (timestamp BIGINT, ip TINYTEXT NOT NULL PRIMARY KEY, playerCount MEDIUMINT)', handleError)
|
||||
this._sql.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)', handleError)
|
||||
this._sql.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)', [], err => {
|
||||
handleError(err)
|
||||
this._sql.run(
|
||||
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
|
||||
handleError
|
||||
);
|
||||
this._sql.run(
|
||||
"CREATE TABLE IF NOT EXISTS players_record (timestamp BIGINT, ip TINYTEXT NOT NULL PRIMARY KEY, playerCount MEDIUMINT)",
|
||||
handleError
|
||||
);
|
||||
this._sql.run(
|
||||
"CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)",
|
||||
handleError
|
||||
);
|
||||
this._sql.run(
|
||||
"CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)",
|
||||
[],
|
||||
(err) => {
|
||||
handleError(err);
|
||||
// Queries are executed one at a time; this is the last one.
|
||||
// Note that queries not scheduled directly in the callback function of
|
||||
// #serialize are not necessarily serialized.
|
||||
callback()
|
||||
})
|
||||
})
|
||||
callback();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
loadGraphPoints (graphDuration, callback) {
|
||||
loadGraphPoints(graphDuration, callback) {
|
||||
// Query recent pings
|
||||
const endTime = TimeTracker.getEpochMillis()
|
||||
const startTime = endTime - graphDuration
|
||||
const endTime = TimeTracker.getEpochMillis();
|
||||
const startTime = endTime - graphDuration;
|
||||
|
||||
this.getRecentPings(startTime, endTime, pingData => {
|
||||
const relativeGraphData = []
|
||||
this.getRecentPings(startTime, endTime, (pingData) => {
|
||||
const relativeGraphData = [];
|
||||
|
||||
for (const row of pingData) {
|
||||
// Load into temporary array
|
||||
// This will be culled prior to being pushed to the serverRegistration
|
||||
let graphData = relativeGraphData[row.ip]
|
||||
let graphData = relativeGraphData[row.ip];
|
||||
if (!graphData) {
|
||||
relativeGraphData[row.ip] = graphData = [[], []]
|
||||
relativeGraphData[row.ip] = graphData = [[], []];
|
||||
}
|
||||
|
||||
// DANGER!
|
||||
// This will pull the timestamp from each row into memory
|
||||
// This is built under the assumption that each round of pings shares the same timestamp
|
||||
// This enables all timestamp arrays to have consistent point selection and graph correctly
|
||||
graphData[0].push(row.timestamp)
|
||||
graphData[1].push(row.playerCount)
|
||||
graphData[0].push(row.timestamp);
|
||||
graphData[1].push(row.playerCount);
|
||||
}
|
||||
|
||||
Object.keys(relativeGraphData).forEach(ip => {
|
||||
Object.keys(relativeGraphData).forEach((ip) => {
|
||||
// Match IPs to serverRegistration object
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
if (serverRegistration.data.ip === ip) {
|
||||
const graphData = relativeGraphData[ip]
|
||||
const graphData = relativeGraphData[ip];
|
||||
|
||||
// Push the data into the instance and cull if needed
|
||||
serverRegistration.loadGraphPoints(startTime, graphData[0], graphData[1])
|
||||
serverRegistration.loadGraphPoints(
|
||||
startTime,
|
||||
graphData[0],
|
||||
graphData[1]
|
||||
);
|
||||
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Since all timestamps are shared, use the array from the first ServerRegistration
|
||||
// This is very dangerous and can break if data is out of sync
|
||||
if (Object.keys(relativeGraphData).length > 0) {
|
||||
const serverIp = Object.keys(relativeGraphData)[0]
|
||||
const timestamps = relativeGraphData[serverIp][0]
|
||||
const serverIp = Object.keys(relativeGraphData)[0];
|
||||
const timestamps = relativeGraphData[serverIp][0];
|
||||
|
||||
this._app.timeTracker.loadGraphPoints(startTime, timestamps)
|
||||
this._app.timeTracker.loadGraphPoints(startTime, timestamps);
|
||||
}
|
||||
|
||||
callback()
|
||||
})
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
loadRecords (callback) {
|
||||
let completedTasks = 0
|
||||
loadRecords(callback) {
|
||||
let completedTasks = 0;
|
||||
|
||||
this._app.serverRegistrations.forEach(serverRegistration => {
|
||||
this._app.serverRegistrations.forEach((serverRegistration) => {
|
||||
// Find graphPeaks
|
||||
// This pre-computes the values prior to clients connecting
|
||||
serverRegistration.findNewGraphPeak()
|
||||
serverRegistration.findNewGraphPeak();
|
||||
|
||||
// Query recordData
|
||||
// When complete increment completeTasks to know when complete
|
||||
this.getRecord(serverRegistration.data.ip, (hasRecord, playerCount, timestamp) => {
|
||||
this.getRecord(
|
||||
serverRegistration.data.ip,
|
||||
(hasRecord, playerCount, timestamp) => {
|
||||
if (hasRecord) {
|
||||
serverRegistration.recordData = {
|
||||
playerCount,
|
||||
timestamp: TimeTracker.toSeconds(timestamp)
|
||||
}
|
||||
timestamp: TimeTracker.toSeconds(timestamp),
|
||||
};
|
||||
} else {
|
||||
this.getRecordLegacy(serverRegistration.data.ip, (hasRecordLegacy, playerCountLegacy, timestampLegacy) => {
|
||||
this.getRecordLegacy(
|
||||
serverRegistration.data.ip,
|
||||
(hasRecordLegacy, playerCountLegacy, timestampLegacy) => {
|
||||
// New values that will be inserted to table
|
||||
let newTimestamp = null
|
||||
let newPlayerCount = null
|
||||
let newTimestamp = null;
|
||||
let newPlayerCount = null;
|
||||
|
||||
// If legacy record found, use it for insertion
|
||||
if (hasRecordLegacy) {
|
||||
newTimestamp = timestampLegacy
|
||||
newPlayerCount = playerCountLegacy
|
||||
newTimestamp = timestampLegacy;
|
||||
newPlayerCount = playerCountLegacy;
|
||||
}
|
||||
|
||||
// Set record to recordData
|
||||
serverRegistration.recordData = {
|
||||
playerCount: newPlayerCount,
|
||||
timestamp: TimeTracker.toSeconds(newTimestamp)
|
||||
}
|
||||
timestamp: TimeTracker.toSeconds(newTimestamp),
|
||||
};
|
||||
|
||||
// Insert server entry to records table
|
||||
const statement = this._sql.prepare('INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)')
|
||||
statement.run(newTimestamp, serverRegistration.data.ip, newPlayerCount, err => {
|
||||
const statement = this._sql.prepare(
|
||||
"INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)"
|
||||
);
|
||||
statement.run(
|
||||
newTimestamp,
|
||||
serverRegistration.data.ip,
|
||||
newPlayerCount,
|
||||
(err) => {
|
||||
if (err) {
|
||||
logger.error(`Cannot insert initial player count record of ${serverRegistration.data.ip}`)
|
||||
throw err
|
||||
logger.error(
|
||||
`Cannot insert initial player count record of ${serverRegistration.data.ip}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
statement.finalize()
|
||||
})
|
||||
}
|
||||
);
|
||||
statement.finalize();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check if completedTasks hit the finish value
|
||||
// Fire callback since #readyDatabase is complete
|
||||
if (++completedTasks === this._app.serverRegistrations.length) {
|
||||
callback()
|
||||
callback();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getRecentPings (startTime, endTime, callback) {
|
||||
this._sql.all('SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?', [
|
||||
startTime,
|
||||
endTime
|
||||
], (err, data) => {
|
||||
getRecentPings(startTime, endTime, callback) {
|
||||
this._sql.all(
|
||||
"SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?",
|
||||
[startTime, endTime],
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
logger.log('error', 'Cannot get recent pings')
|
||||
throw err
|
||||
logger.log("error", "Cannot get recent pings");
|
||||
throw err;
|
||||
}
|
||||
callback(data)
|
||||
})
|
||||
callback(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getRecord (ip, callback) {
|
||||
this._sql.all('SELECT playerCount, timestamp FROM players_record WHERE ip = ?', [
|
||||
ip
|
||||
], (err, data) => {
|
||||
getRecord(ip, callback) {
|
||||
this._sql.all(
|
||||
"SELECT playerCount, timestamp FROM players_record WHERE ip = ?",
|
||||
[ip],
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
logger.log('error', `Cannot get ping record for ${ip}`)
|
||||
throw err
|
||||
logger.log("error", `Cannot get ping record for ${ip}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Record not found
|
||||
if (data[0] === undefined) {
|
||||
// eslint-disable-next-line node/no-callback-literal
|
||||
callback(false)
|
||||
return
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerCount = data[0].playerCount
|
||||
const timestamp = data[0].timestamp
|
||||
const playerCount = data[0].playerCount;
|
||||
const timestamp = data[0].timestamp;
|
||||
|
||||
// Allow null player counts and timestamps, the frontend will safely handle them
|
||||
// eslint-disable-next-line node/no-callback-literal
|
||||
callback(true, playerCount, timestamp)
|
||||
})
|
||||
callback(true, playerCount, timestamp);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieves record from pings table, used for converting to separate table
|
||||
getRecordLegacy (ip, callback) {
|
||||
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [
|
||||
ip
|
||||
], (err, data) => {
|
||||
getRecordLegacy(ip, callback) {
|
||||
this._sql.all(
|
||||
"SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?",
|
||||
[ip],
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
logger.log('error', `Cannot get legacy ping record for ${ip}`)
|
||||
throw err
|
||||
logger.log("error", `Cannot get legacy ping record for ${ip}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// For empty results, data will be length 1 with [null, null]
|
||||
const playerCount = data[0]['MAX(playerCount)']
|
||||
const timestamp = data[0].timestamp
|
||||
const playerCount = data[0]["MAX(playerCount)"];
|
||||
const timestamp = data[0].timestamp;
|
||||
|
||||
// Allow null timestamps, the frontend will safely handle them
|
||||
// This allows insertion of free standing records without a known timestamp
|
||||
if (playerCount !== null) {
|
||||
// eslint-disable-next-line node/no-callback-literal
|
||||
callback(true, playerCount, timestamp)
|
||||
callback(true, playerCount, timestamp);
|
||||
} else {
|
||||
// eslint-disable-next-line node/no-callback-literal
|
||||
callback(false)
|
||||
callback(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
insertPing (ip, timestamp, unsafePlayerCount) {
|
||||
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql)
|
||||
insertPing(ip, timestamp, unsafePlayerCount) {
|
||||
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql);
|
||||
|
||||
// Push a copy of the data into the database copy, if any
|
||||
// This creates an "insert only" copy of the database for archiving
|
||||
const dailyDatabase = this.getDailyDatabase()
|
||||
const dailyDatabase = this.getDailyDatabase();
|
||||
if (dailyDatabase) {
|
||||
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase)
|
||||
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase);
|
||||
}
|
||||
}
|
||||
|
||||
_insertPingTo (ip, timestamp, unsafePlayerCount, db) {
|
||||
const statement = db.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)')
|
||||
statement.run(timestamp, ip, unsafePlayerCount, err => {
|
||||
_insertPingTo(ip, timestamp, unsafePlayerCount, db) {
|
||||
const statement = db.prepare(
|
||||
"INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)"
|
||||
);
|
||||
statement.run(timestamp, ip, unsafePlayerCount, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`)
|
||||
throw err
|
||||
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
statement.finalize()
|
||||
});
|
||||
statement.finalize();
|
||||
}
|
||||
|
||||
updatePlayerCountRecord (ip, playerCount, timestamp) {
|
||||
const statement = this._sql.prepare('UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?')
|
||||
statement.run(timestamp, playerCount, ip, err => {
|
||||
updatePlayerCountRecord(ip, playerCount, timestamp) {
|
||||
const statement = this._sql.prepare(
|
||||
"UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?"
|
||||
);
|
||||
statement.run(timestamp, playerCount, ip, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Cannot update player count record of ${ip} at ${timestamp}`)
|
||||
throw err
|
||||
logger.error(
|
||||
`Cannot update player count record of ${ip} at ${timestamp}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
statement.finalize()
|
||||
});
|
||||
statement.finalize();
|
||||
}
|
||||
|
||||
initOldPingsDelete (callback) {
|
||||
initOldPingsDelete(callback) {
|
||||
// Delete old pings on startup
|
||||
logger.info('Deleting old pings..')
|
||||
logger.info("Deleting old pings..");
|
||||
this.deleteOldPings(() => {
|
||||
const oldPingsCleanupInterval = config.oldPingsCleanup.interval || 3600000
|
||||
const oldPingsCleanupInterval =
|
||||
config.oldPingsCleanup.interval || 3600000;
|
||||
if (oldPingsCleanupInterval > 0) {
|
||||
// Delete old pings periodically
|
||||
setInterval(() => this.deleteOldPings(), oldPingsCleanupInterval)
|
||||
setInterval(() => this.deleteOldPings(), oldPingsCleanupInterval);
|
||||
}
|
||||
|
||||
callback()
|
||||
})
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
deleteOldPings (callback) {
|
||||
deleteOldPings(callback) {
|
||||
// The oldest timestamp that will be kept
|
||||
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration
|
||||
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration;
|
||||
|
||||
const deleteStart = TimeTracker.getEpochMillis()
|
||||
const statement = this._sql.prepare('DELETE FROM pings WHERE timestamp < ?;')
|
||||
statement.run(oldestTimestamp, err => {
|
||||
const deleteStart = TimeTracker.getEpochMillis();
|
||||
const statement = this._sql.prepare(
|
||||
"DELETE FROM pings WHERE timestamp < ?;"
|
||||
);
|
||||
statement.run(oldestTimestamp, (err) => {
|
||||
if (err) {
|
||||
logger.error('Cannot delete old pings')
|
||||
throw err
|
||||
logger.error("Cannot delete old pings");
|
||||
throw err;
|
||||
} else {
|
||||
const deleteTook = TimeTracker.getEpochMillis() - deleteStart
|
||||
logger.info(`Old pings deleted in ${deleteTook}ms`)
|
||||
const deleteTook = TimeTracker.getEpochMillis() - deleteStart;
|
||||
logger.info(`Old pings deleted in ${deleteTook}ms`);
|
||||
|
||||
if (callback) {
|
||||
callback()
|
||||
callback();
|
||||
}
|
||||
}
|
||||
})
|
||||
statement.finalize()
|
||||
});
|
||||
statement.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Database
|
||||
module.exports = Database;
|
||||
|
83
lib/dns.js
83
lib/dns.js
@ -1,78 +1,97 @@
|
||||
const dns = require('dns')
|
||||
const dns = require("dns");
|
||||
|
||||
const logger = require('./logger')
|
||||
const logger = require("./logger");
|
||||
|
||||
const { TimeTracker } = require('./time')
|
||||
const { TimeTracker } = require("./time");
|
||||
|
||||
const config = require('../config')
|
||||
const config = require("../config");
|
||||
|
||||
const SKIP_SRV_TIMEOUT = config.skipSrvTimeout || 60 * 60 * 1000
|
||||
const SKIP_SRV_TIMEOUT = config.skipSrvTimeout || 60 * 60 * 1000;
|
||||
|
||||
class DNSResolver {
|
||||
constructor (ip, port) {
|
||||
this._ip = ip
|
||||
this._port = port
|
||||
constructor(ip, port) {
|
||||
this._ip = ip;
|
||||
this._port = port;
|
||||
}
|
||||
|
||||
_skipSrv () {
|
||||
this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT
|
||||
_skipSrv() {
|
||||
this._skipSrvUntil = TimeTracker.getEpochMillis() + SKIP_SRV_TIMEOUT;
|
||||
}
|
||||
|
||||
_isSkipSrv () {
|
||||
return this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil
|
||||
_isSkipSrv() {
|
||||
return (
|
||||
this._skipSrvUntil && TimeTracker.getEpochMillis() <= this._skipSrvUntil
|
||||
);
|
||||
}
|
||||
|
||||
resolve (callback) {
|
||||
resolve(callback) {
|
||||
if (this._isSkipSrv()) {
|
||||
callback(this._ip, this._port, config.rates.connectTimeout)
|
||||
callback(this._ip, this._port, config.rates.connectTimeout);
|
||||
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = TimeTracker.getEpochMillis()
|
||||
const startTime = TimeTracker.getEpochMillis();
|
||||
|
||||
let callbackFired = false
|
||||
let callbackFired = false;
|
||||
|
||||
const fireCallback = (ip, port) => {
|
||||
if (!callbackFired) {
|
||||
callbackFired = true
|
||||
callbackFired = true;
|
||||
|
||||
// Send currentTime - startTime to provide remaining connectionTime allowance
|
||||
const remainingTime = config.rates.connectTimeout - (TimeTracker.getEpochMillis() - startTime)
|
||||
const remainingTime =
|
||||
config.rates.connectTimeout -
|
||||
(TimeTracker.getEpochMillis() - startTime);
|
||||
|
||||
callback(ip || this._ip, port || this._port, remainingTime)
|
||||
}
|
||||
callback(ip || this._ip, port || this._port, remainingTime);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutCallback = setTimeout(fireCallback, config.rates.connectTimeout)
|
||||
const timeoutCallback = setTimeout(
|
||||
fireCallback,
|
||||
config.rates.connectTimeout
|
||||
);
|
||||
|
||||
dns.resolveSrv('_minecraft._tcp.' + this._ip, (err, records) => {
|
||||
dns.resolveSrv("_minecraft._tcp." + this._ip, (err, records) => {
|
||||
// Cancel the timeout handler if not already fired
|
||||
if (!callbackFired) {
|
||||
clearTimeout(timeoutCallback)
|
||||
clearTimeout(timeoutCallback);
|
||||
}
|
||||
|
||||
// Test if the error indicates a miss, or if the records returned are empty
|
||||
if ((err && (err.code === 'ENOTFOUND' || err.code === 'ENODATA')) || !records || records.length === 0) {
|
||||
if (
|
||||
(err && (err.code === "ENOTFOUND" || err.code === "ENODATA")) ||
|
||||
!records ||
|
||||
records.length === 0
|
||||
) {
|
||||
// Compare config.skipSrvTimeout directly since SKIP_SRV_TIMEOUT has an or'd value
|
||||
// isSkipSrvTimeoutDisabled == whether the config has a valid skipSrvTimeout value set
|
||||
const isSkipSrvTimeoutDisabled = typeof config.skipSrvTimeout === 'number' && config.skipSrvTimeout === 0
|
||||
const isSkipSrvTimeoutDisabled =
|
||||
typeof config.skipSrvTimeout === "number" &&
|
||||
config.skipSrvTimeout === 0;
|
||||
|
||||
// Only activate _skipSrv if the skipSrvTimeout value is either NaN or > 0
|
||||
// 0 represents a disabled flag
|
||||
if (!this._isSkipSrv() && !isSkipSrvTimeoutDisabled) {
|
||||
this._skipSrv()
|
||||
this._skipSrv();
|
||||
|
||||
logger.log('warn', 'No SRV records were resolved for %s. Minetrack will skip attempting to resolve %s SRV records for %d minutes.', this._ip, this._ip, SKIP_SRV_TIMEOUT / (60 * 1000))
|
||||
logger.log(
|
||||
"warn",
|
||||
"No SRV records were resolved for %s. Minetrack will skip attempting to resolve %s SRV records for %d minutes.",
|
||||
this._ip,
|
||||
this._ip,
|
||||
SKIP_SRV_TIMEOUT / (60 * 1000)
|
||||
);
|
||||
}
|
||||
|
||||
fireCallback()
|
||||
fireCallback();
|
||||
} else {
|
||||
// Only fires if !err && records.length > 0
|
||||
fireCallback(records[0].name, records[0].port)
|
||||
fireCallback(records[0].name, records[0].port);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DNSResolver
|
||||
module.exports = DNSResolver;
|
||||
|
@ -1,17 +1,17 @@
|
||||
const winston = require('winston')
|
||||
const winston = require("winston");
|
||||
|
||||
winston.remove(winston.transports.Console)
|
||||
winston.remove(winston.transports.Console);
|
||||
|
||||
winston.add(winston.transports.File, {
|
||||
filename: 'minetrack.log'
|
||||
})
|
||||
filename: "minetrack.log",
|
||||
});
|
||||
|
||||
winston.add(winston.transports.Console, {
|
||||
timestamp: () => {
|
||||
const date = new Date()
|
||||
return date.toLocaleTimeString() + ' ' + date.toLocaleDateString()
|
||||
const date = new Date();
|
||||
return date.toLocaleTimeString() + " " + date.toLocaleDateString();
|
||||
},
|
||||
colorize: true
|
||||
})
|
||||
colorize: true,
|
||||
});
|
||||
|
||||
module.exports = winston
|
||||
module.exports = winston;
|
||||
|
@ -1,6 +1,6 @@
|
||||
module.exports = function MessageOf (name, data) {
|
||||
module.exports = function MessageOf(name, data) {
|
||||
return JSON.stringify({
|
||||
message: name,
|
||||
...data
|
||||
})
|
||||
}
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
192
lib/ping.js
192
lib/ping.js
@ -1,159 +1,213 @@
|
||||
const minecraftJavaPing = require('mcping-js')
|
||||
const minecraftBedrockPing = require('mcpe-ping-fixed')
|
||||
const minecraftJavaPing = require("mcping-js");
|
||||
const minecraftBedrockPing = require("mcpe-ping-fixed");
|
||||
|
||||
const logger = require('./logger')
|
||||
const MessageOf = require('./message')
|
||||
const { TimeTracker } = require('./time')
|
||||
const logger = require("./logger");
|
||||
const MessageOf = require("./message");
|
||||
const { TimeTracker } = require("./time");
|
||||
|
||||
const { getPlayerCountOrNull } = require('./util')
|
||||
const { getPlayerCountOrNull } = require("./util");
|
||||
|
||||
const config = require('../config')
|
||||
const config = require("../config");
|
||||
|
||||
function ping (serverRegistration, timeout, callback, version) {
|
||||
function ping(serverRegistration, timeout, callback, version) {
|
||||
switch (serverRegistration.data.type) {
|
||||
case 'PC':
|
||||
case "PC":
|
||||
serverRegistration.dnsResolver.resolve((host, port, remainingTimeout) => {
|
||||
const server = new minecraftJavaPing.MinecraftServer(host, port || 25565)
|
||||
const server = new minecraftJavaPing.MinecraftServer(
|
||||
host,
|
||||
port || 25565
|
||||
);
|
||||
|
||||
server.ping(remainingTimeout, version, (err, res) => {
|
||||
if (err) {
|
||||
callback(err)
|
||||
callback(err);
|
||||
} else {
|
||||
const payload = {
|
||||
players: {
|
||||
online: capPlayerCount(serverRegistration.data.ip, parseInt(res.players.online))
|
||||
online: capPlayerCount(
|
||||
serverRegistration.data.ip,
|
||||
parseInt(res.players.online)
|
||||
),
|
||||
},
|
||||
version: parseInt(res.version.protocol)
|
||||
}
|
||||
version: parseInt(res.version.protocol),
|
||||
};
|
||||
|
||||
// Ensure the returned favicon is a data URI
|
||||
if (res.favicon && res.favicon.startsWith('data:image/')) {
|
||||
payload.favicon = res.favicon
|
||||
if (res.favicon && res.favicon.startsWith("data:image/")) {
|
||||
payload.favicon = res.favicon;
|
||||
}
|
||||
|
||||
callback(null, payload)
|
||||
callback(null, payload);
|
||||
}
|
||||
})
|
||||
})
|
||||
break
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case 'PE':
|
||||
minecraftBedrockPing(serverRegistration.data.ip, serverRegistration.data.port || 19132, (err, res) => {
|
||||
case "PE":
|
||||
minecraftBedrockPing(
|
||||
serverRegistration.data.ip,
|
||||
serverRegistration.data.port || 19132,
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
callback(err)
|
||||
callback(err);
|
||||
} else {
|
||||
callback(null, {
|
||||
players: {
|
||||
online: capPlayerCount(serverRegistration.data.ip, parseInt(res.currentPlayers))
|
||||
online: capPlayerCount(
|
||||
serverRegistration.data.ip,
|
||||
parseInt(res.currentPlayers)
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}, timeout)
|
||||
break
|
||||
},
|
||||
timeout
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported type: ' + serverRegistration.data.type)
|
||||
throw new Error("Unsupported type: " + serverRegistration.data.type);
|
||||
}
|
||||
}
|
||||
|
||||
// player count can be up to 1^32-1, which is a massive scale and destroys browser performance when rendering graphs
|
||||
// Artificially cap and warn to prevent propogating garbage
|
||||
function capPlayerCount (host, playerCount) {
|
||||
const maxPlayerCount = 250000
|
||||
function capPlayerCount(host, playerCount) {
|
||||
const maxPlayerCount = 250000;
|
||||
|
||||
if (playerCount !== Math.min(playerCount, maxPlayerCount)) {
|
||||
logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount)
|
||||
logger.log(
|
||||
"warn",
|
||||
"%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!",
|
||||
host,
|
||||
playerCount,
|
||||
maxPlayerCount
|
||||
);
|
||||
|
||||
return maxPlayerCount
|
||||
return maxPlayerCount;
|
||||
} else if (playerCount !== Math.max(playerCount, 0)) {
|
||||
logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount)
|
||||
logger.log(
|
||||
"warn",
|
||||
"%s returned an invalid player count of %d, setting to 0.",
|
||||
host,
|
||||
playerCount
|
||||
);
|
||||
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
return playerCount
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
class PingController {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._isRunningTasks = false
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._isRunningTasks = false;
|
||||
}
|
||||
|
||||
schedule () {
|
||||
setInterval(this.pingAll, config.rates.pingAll)
|
||||
schedule() {
|
||||
setInterval(this.pingAll, config.rates.pingAll);
|
||||
// todo: make this a cron job?
|
||||
|
||||
this.pingAll()
|
||||
this.pingAll();
|
||||
}
|
||||
|
||||
pingAll = () => {
|
||||
const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp()
|
||||
const { timestamp, updateHistoryGraph } =
|
||||
this._app.timeTracker.newPointTimestamp();
|
||||
|
||||
this.startPingTasks(results => {
|
||||
const updates = []
|
||||
this.startPingTasks((results) => {
|
||||
const updates = [];
|
||||
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
const result = results[serverRegistration.serverId]
|
||||
const result = results[serverRegistration.serverId];
|
||||
|
||||
// Log to database if enabled
|
||||
// Use null to represent a failed ping
|
||||
if (config.logToDatabase) {
|
||||
const unsafePlayerCount = getPlayerCountOrNull(result.resp)
|
||||
const unsafePlayerCount = getPlayerCountOrNull(result.resp);
|
||||
|
||||
this._app.database.insertPing(serverRegistration.data.ip, timestamp, unsafePlayerCount)
|
||||
this._app.database.insertPing(
|
||||
serverRegistration.data.ip,
|
||||
timestamp,
|
||||
unsafePlayerCount
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a combined update payload
|
||||
// This includes any modified fields and flags used by the frontend
|
||||
// This will not be cached and can contain live metadata
|
||||
const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version, updateHistoryGraph)
|
||||
const update = serverRegistration.handlePing(
|
||||
timestamp,
|
||||
result.resp,
|
||||
result.err,
|
||||
result.version,
|
||||
updateHistoryGraph
|
||||
);
|
||||
|
||||
updates[serverRegistration.serverId] = update
|
||||
updates[serverRegistration.serverId] = update;
|
||||
}
|
||||
|
||||
// Send object since updates uses serverIds as keys
|
||||
// Send a single timestamp entry since it is shared
|
||||
this._app.server.broadcast(MessageOf('updateServers', {
|
||||
this._app.server.broadcast(
|
||||
MessageOf("updateServers", {
|
||||
timestamp: TimeTracker.toSeconds(timestamp),
|
||||
updateHistoryGraph,
|
||||
updates
|
||||
}))
|
||||
updates,
|
||||
})
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
startPingTasks = (callback) => {
|
||||
if (this._isRunningTasks) {
|
||||
logger.log('warn', 'Started re-pinging servers before the last loop has finished! You may need to increase "rates.pingAll" in config.json')
|
||||
logger.log(
|
||||
"warn",
|
||||
'Started re-pinging servers before the last loop has finished! You may need to increase "rates.pingAll" in config.json'
|
||||
);
|
||||
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this._isRunningTasks = true
|
||||
this._isRunningTasks = true;
|
||||
|
||||
const results = []
|
||||
const results = [];
|
||||
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
const version = serverRegistration.getNextProtocolVersion()
|
||||
const version = serverRegistration.getNextProtocolVersion();
|
||||
|
||||
ping(serverRegistration, config.rates.connectTimeout, (err, resp) => {
|
||||
ping(
|
||||
serverRegistration,
|
||||
config.rates.connectTimeout,
|
||||
(err, resp) => {
|
||||
if (err && config.logFailedPings !== false) {
|
||||
logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message)
|
||||
logger.log(
|
||||
"error",
|
||||
"Failed to ping %s: %s",
|
||||
serverRegistration.data.ip,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
results[serverRegistration.serverId] = {
|
||||
resp,
|
||||
err,
|
||||
version
|
||||
}
|
||||
version,
|
||||
};
|
||||
|
||||
if (Object.keys(results).length === this._app.serverRegistrations.length) {
|
||||
if (
|
||||
Object.keys(results).length === this._app.serverRegistrations.length
|
||||
) {
|
||||
// Loop has completed, release the locking flag
|
||||
this._isRunningTasks = false
|
||||
this._isRunningTasks = false;
|
||||
|
||||
callback(results)
|
||||
}
|
||||
}, version.protocolId)
|
||||
callback(results);
|
||||
}
|
||||
},
|
||||
version.protocolId
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = PingController
|
||||
module.exports = PingController;
|
||||
|
153
lib/server.js
153
lib/server.js
@ -1,114 +1,139 @@
|
||||
const http = require('http')
|
||||
const format = require('util').format
|
||||
const http = require("http");
|
||||
const format = require("util").format;
|
||||
|
||||
const WebSocket = require('ws')
|
||||
const finalHttpHandler = require('finalhandler')
|
||||
const serveStatic = require('serve-static')
|
||||
const WebSocket = require("ws");
|
||||
const finalHttpHandler = require("finalhandler");
|
||||
const serveStatic = require("serve-static");
|
||||
|
||||
const logger = require('./logger')
|
||||
const logger = require("./logger");
|
||||
|
||||
const HASHED_FAVICON_URL_REGEX = /hashedfavicon_([a-z0-9]{32}).png/g
|
||||
const HASHED_FAVICON_URL_REGEX = /hashedfavicon_([a-z0-9]{32}).png/g;
|
||||
|
||||
function getRemoteAddr (req) {
|
||||
return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress
|
||||
function getRemoteAddr(req) {
|
||||
return (
|
||||
req.headers["cf-connecting-ip"] ||
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress
|
||||
);
|
||||
}
|
||||
|
||||
class Server {
|
||||
static getHashedFaviconUrl (hash) {
|
||||
static getHashedFaviconUrl(hash) {
|
||||
// Format must be compatible with HASHED_FAVICON_URL_REGEX
|
||||
return format('/hashedfavicon_%s.png', hash)
|
||||
return format("/hashedfavicon_%s.png", hash);
|
||||
}
|
||||
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
|
||||
this.createHttpServer()
|
||||
this.createWebSocketServer()
|
||||
this.createHttpServer();
|
||||
this.createWebSocketServer();
|
||||
}
|
||||
|
||||
createHttpServer () {
|
||||
const distServeStatic = serveStatic('dist/')
|
||||
const faviconsServeStatic = serveStatic('favicons/')
|
||||
createHttpServer() {
|
||||
const distServeStatic = serveStatic("dist/");
|
||||
const faviconsServeStatic = serveStatic("favicons/");
|
||||
|
||||
this._http = http.createServer((req, res) => {
|
||||
logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url)
|
||||
logger.log("info", "%s requested: %s", getRemoteAddr(req), req.url);
|
||||
|
||||
// Test the URL against a regex for hashed favicon URLs
|
||||
// Require only 1 match ([0]) and test its first captured group ([1])
|
||||
// Any invalid value or hit miss will pass into static handlers below
|
||||
const faviconHash = [...req.url.matchAll(HASHED_FAVICON_URL_REGEX)]
|
||||
const faviconHash = [...req.url.matchAll(HASHED_FAVICON_URL_REGEX)];
|
||||
|
||||
if (faviconHash.length === 1 && this.handleFaviconRequest(res, faviconHash[0][1])) {
|
||||
return
|
||||
if (
|
||||
faviconHash.length === 1 &&
|
||||
this.handleFaviconRequest(res, faviconHash[0][1])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic
|
||||
// If faviconServeStatic fails, pass to finalHttpHandler to terminate
|
||||
distServeStatic(req, res, () => {
|
||||
faviconsServeStatic(req, res, finalHttpHandler(req, res))
|
||||
})
|
||||
})
|
||||
faviconsServeStatic(req, res, finalHttpHandler(req, res));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleFaviconRequest = (res, faviconHash) => {
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
if (serverRegistration.faviconHash && serverRegistration.faviconHash === faviconHash) {
|
||||
const buf = Buffer.from(serverRegistration.lastFavicon.split(',')[1], 'base64')
|
||||
if (
|
||||
serverRegistration.faviconHash &&
|
||||
serverRegistration.faviconHash === faviconHash
|
||||
) {
|
||||
const buf = Buffer.from(
|
||||
serverRegistration.lastFavicon.split(",")[1],
|
||||
"base64"
|
||||
);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buf.length,
|
||||
'Cache-Control': 'public, max-age=604800' // Cache hashed favicon for 7 days
|
||||
}).end(buf)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
createWebSocketServer () {
|
||||
this._wss = new WebSocket.Server({
|
||||
server: this._http
|
||||
res
|
||||
.writeHead(200, {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": buf.length,
|
||||
"Cache-Control": "public, max-age=604800", // Cache hashed favicon for 7 days
|
||||
})
|
||||
.end(buf);
|
||||
|
||||
this._wss.on('connection', (client, req) => {
|
||||
logger.log('info', '%s connected, total clients: %d', getRemoteAddr(req), this.getConnectedClients())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
createWebSocketServer() {
|
||||
this._wss = new WebSocket.Server({
|
||||
server: this._http,
|
||||
});
|
||||
|
||||
this._wss.on("connection", (client, req) => {
|
||||
logger.log(
|
||||
"info",
|
||||
"%s connected, total clients: %d",
|
||||
getRemoteAddr(req),
|
||||
this.getConnectedClients()
|
||||
);
|
||||
|
||||
// Bind disconnect event for logging
|
||||
client.on('close', () => {
|
||||
logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(req), this.getConnectedClients())
|
||||
})
|
||||
client.on("close", () => {
|
||||
logger.log(
|
||||
"info",
|
||||
"%s disconnected, total clients: %d",
|
||||
getRemoteAddr(req),
|
||||
this.getConnectedClients()
|
||||
);
|
||||
});
|
||||
|
||||
// Pass client off to proxy handler
|
||||
this._app.handleClientConnection(client)
|
||||
})
|
||||
this._app.handleClientConnection(client);
|
||||
});
|
||||
}
|
||||
|
||||
listen (host, port) {
|
||||
this._http.listen(port, host)
|
||||
listen(host, port) {
|
||||
this._http.listen(port, host);
|
||||
|
||||
logger.log('info', 'Started on %s:%d', host, port)
|
||||
logger.log("info", "Started on %s:%d", host, port);
|
||||
}
|
||||
|
||||
broadcast (payload) {
|
||||
this._wss.clients.forEach(client => {
|
||||
broadcast(payload) {
|
||||
this._wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(payload)
|
||||
client.send(payload);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
getConnectedClients () {
|
||||
let count = 0
|
||||
this._wss.clients.forEach(client => {
|
||||
getConnectedClients() {
|
||||
let count = 0;
|
||||
this._wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
count++
|
||||
count++;
|
||||
}
|
||||
})
|
||||
return count
|
||||
});
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Server
|
||||
module.exports = Server;
|
||||
|
240
lib/servers.js
240
lib/servers.js
@ -1,263 +1,297 @@
|
||||
const crypto = require('crypto')
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DNSResolver = require('./dns')
|
||||
const Server = require('./server')
|
||||
const DNSResolver = require("./dns");
|
||||
const Server = require("./server");
|
||||
|
||||
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time')
|
||||
const { getPlayerCountOrNull } = require('./util')
|
||||
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require("./time");
|
||||
const { getPlayerCountOrNull } = require("./util");
|
||||
|
||||
const config = require('../config')
|
||||
const minecraftVersions = require('../minecraft_versions')
|
||||
const config = require("../config");
|
||||
const minecraftVersions = require("../minecraft_versions");
|
||||
|
||||
class ServerRegistration {
|
||||
serverId
|
||||
lastFavicon
|
||||
versions = []
|
||||
recordData
|
||||
graphData = []
|
||||
serverId;
|
||||
lastFavicon;
|
||||
versions = [];
|
||||
recordData;
|
||||
graphData = [];
|
||||
|
||||
constructor (app, serverId, data) {
|
||||
this._app = app
|
||||
this.serverId = serverId
|
||||
this.data = data
|
||||
this._pingHistory = []
|
||||
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port)
|
||||
constructor(app, serverId, data) {
|
||||
this._app = app;
|
||||
this.serverId = serverId;
|
||||
this.data = data;
|
||||
this._pingHistory = [];
|
||||
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port);
|
||||
}
|
||||
|
||||
handlePing (timestamp, resp, err, version, updateHistoryGraph) {
|
||||
handlePing(timestamp, resp, err, version, updateHistoryGraph) {
|
||||
// Use null to represent a failed ping
|
||||
const unsafePlayerCount = getPlayerCountOrNull(resp)
|
||||
const unsafePlayerCount = getPlayerCountOrNull(resp);
|
||||
|
||||
// Store into in-memory ping data
|
||||
TimeTracker.pushAndShift(this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength())
|
||||
TimeTracker.pushAndShift(
|
||||
this._pingHistory,
|
||||
unsafePlayerCount,
|
||||
TimeTracker.getMaxServerGraphDataLength()
|
||||
);
|
||||
|
||||
// Only notify the frontend to append to the historical graph
|
||||
// if both the graphing behavior is enabled and the backend agrees
|
||||
// that the ping is eligible for addition
|
||||
if (updateHistoryGraph) {
|
||||
TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength())
|
||||
TimeTracker.pushAndShift(
|
||||
this.graphData,
|
||||
unsafePlayerCount,
|
||||
TimeTracker.getMaxGraphDataLength()
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate out update payload generation
|
||||
return this.getUpdate(timestamp, resp, err, version)
|
||||
return this.getUpdate(timestamp, resp, err, version);
|
||||
}
|
||||
|
||||
getUpdate (timestamp, resp, err, version) {
|
||||
const update = {}
|
||||
getUpdate(timestamp, resp, err, version) {
|
||||
const update = {};
|
||||
|
||||
// Always append a playerCount value
|
||||
// When resp is undefined (due to an error), playerCount will be null
|
||||
update.playerCount = getPlayerCountOrNull(resp)
|
||||
update.playerCount = getPlayerCountOrNull(resp);
|
||||
|
||||
if (resp) {
|
||||
if (resp.version && this.updateProtocolVersionCompat(resp.version, version.protocolId, version.protocolIndex)) {
|
||||
if (
|
||||
resp.version &&
|
||||
this.updateProtocolVersionCompat(
|
||||
resp.version,
|
||||
version.protocolId,
|
||||
version.protocolIndex
|
||||
)
|
||||
) {
|
||||
// Append an updated version listing
|
||||
update.versions = this.versions
|
||||
update.versions = this.versions;
|
||||
}
|
||||
|
||||
if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) {
|
||||
if (
|
||||
config.logToDatabase &&
|
||||
(!this.recordData || resp.players.online > this.recordData.playerCount)
|
||||
) {
|
||||
this.recordData = {
|
||||
playerCount: resp.players.online,
|
||||
timestamp: TimeTracker.toSeconds(timestamp)
|
||||
}
|
||||
timestamp: TimeTracker.toSeconds(timestamp),
|
||||
};
|
||||
|
||||
// Append an updated recordData
|
||||
update.recordData = this.recordData
|
||||
update.recordData = this.recordData;
|
||||
|
||||
// Update record in database
|
||||
this._app.database.updatePlayerCountRecord(this.data.ip, resp.players.online, timestamp)
|
||||
this._app.database.updatePlayerCountRecord(
|
||||
this.data.ip,
|
||||
resp.players.online,
|
||||
timestamp
|
||||
);
|
||||
}
|
||||
|
||||
if (this.updateFavicon(resp.favicon)) {
|
||||
update.favicon = this.getFaviconUrl()
|
||||
update.favicon = this.getFaviconUrl();
|
||||
}
|
||||
|
||||
if (config.logToDatabase) {
|
||||
// Update calculated graph peak regardless if the graph is being updated
|
||||
// This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes
|
||||
if (this.findNewGraphPeak()) {
|
||||
update.graphPeakData = this.getGraphPeak()
|
||||
update.graphPeakData = this.getGraphPeak();
|
||||
}
|
||||
}
|
||||
} else if (err) {
|
||||
// Append a filtered copy of err
|
||||
// This ensures any unintended data is not leaked
|
||||
update.error = this.filterError(err)
|
||||
update.error = this.filterError(err);
|
||||
}
|
||||
|
||||
return update
|
||||
return update;
|
||||
}
|
||||
|
||||
getPingHistory () {
|
||||
getPingHistory() {
|
||||
if (this._pingHistory.length > 0) {
|
||||
const payload = {
|
||||
versions: this.versions,
|
||||
recordData: this.recordData,
|
||||
favicon: this.getFaviconUrl()
|
||||
}
|
||||
favicon: this.getFaviconUrl(),
|
||||
};
|
||||
|
||||
// Only append graphPeakData if defined
|
||||
// The value is lazy computed and conditional that config->logToDatabase == true
|
||||
const graphPeakData = this.getGraphPeak()
|
||||
const graphPeakData = this.getGraphPeak();
|
||||
|
||||
if (graphPeakData) {
|
||||
payload.graphPeakData = graphPeakData
|
||||
payload.graphPeakData = graphPeakData;
|
||||
}
|
||||
|
||||
// Assume the ping was a success and define result
|
||||
// pingHistory does not keep error references, so its impossible to detect if this is an error
|
||||
// It is also pointless to store that data since it will be short lived
|
||||
payload.playerCount = this._pingHistory[this._pingHistory.length - 1]
|
||||
payload.playerCount = this._pingHistory[this._pingHistory.length - 1];
|
||||
|
||||
// Send a copy of pingHistory
|
||||
// Include the last value even though it is contained within payload
|
||||
// The frontend will only push to its graphData from playerCountHistory
|
||||
payload.playerCountHistory = this._pingHistory
|
||||
payload.playerCountHistory = this._pingHistory;
|
||||
|
||||
return payload
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
message: 'Pinging...'
|
||||
message: "Pinging...",
|
||||
},
|
||||
recordData: this.recordData,
|
||||
graphPeakData: this.getGraphPeak(),
|
||||
favicon: this.data.favicon
|
||||
}
|
||||
favicon: this.data.favicon,
|
||||
};
|
||||
}
|
||||
|
||||
loadGraphPoints (startTime, timestamps, points) {
|
||||
this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i])
|
||||
loadGraphPoints(startTime, timestamps, points) {
|
||||
this.graphData = TimeTracker.everyN(
|
||||
timestamps,
|
||||
startTime,
|
||||
GRAPH_UPDATE_TIME_GAP,
|
||||
(i) => points[i]
|
||||
);
|
||||
}
|
||||
|
||||
findNewGraphPeak () {
|
||||
let index = -1
|
||||
findNewGraphPeak() {
|
||||
let index = -1;
|
||||
for (let i = 0; i < this.graphData.length; i++) {
|
||||
const point = this.graphData[i]
|
||||
const point = this.graphData[i];
|
||||
if (point !== null && (index === -1 || point > this.graphData[index])) {
|
||||
index = i
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
const lastGraphPeakIndex = this._graphPeakIndex
|
||||
this._graphPeakIndex = index
|
||||
return index !== lastGraphPeakIndex
|
||||
const lastGraphPeakIndex = this._graphPeakIndex;
|
||||
this._graphPeakIndex = index;
|
||||
return index !== lastGraphPeakIndex;
|
||||
} else {
|
||||
this._graphPeakIndex = undefined
|
||||
return false
|
||||
this._graphPeakIndex = undefined;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getGraphPeak () {
|
||||
getGraphPeak() {
|
||||
if (this._graphPeakIndex === undefined) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
return {
|
||||
playerCount: this.graphData[this._graphPeakIndex],
|
||||
timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex)
|
||||
}
|
||||
timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex),
|
||||
};
|
||||
}
|
||||
|
||||
updateFavicon (favicon) {
|
||||
updateFavicon(favicon) {
|
||||
// If data.favicon is defined, then a favicon override is present
|
||||
// Disregard the incoming favicon, regardless if it is different
|
||||
if (this.data.favicon) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (favicon && favicon !== this.lastFavicon) {
|
||||
this.lastFavicon = favicon
|
||||
this.lastFavicon = favicon;
|
||||
|
||||
// Generate an updated hash
|
||||
// This is used by #getFaviconUrl
|
||||
this.faviconHash = crypto.createHash('md5').update(favicon).digest('hex').toString()
|
||||
this.faviconHash = crypto
|
||||
.createHash("md5")
|
||||
.update(favicon)
|
||||
.digest("hex")
|
||||
.toString();
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
getFaviconUrl () {
|
||||
getFaviconUrl() {
|
||||
if (this.faviconHash) {
|
||||
return Server.getHashedFaviconUrl(this.faviconHash)
|
||||
return Server.getHashedFaviconUrl(this.faviconHash);
|
||||
} else if (this.data.favicon) {
|
||||
return this.data.favicon
|
||||
return this.data.favicon;
|
||||
}
|
||||
}
|
||||
|
||||
updateProtocolVersionCompat (incomingId, outgoingId, protocolIndex) {
|
||||
updateProtocolVersionCompat(incomingId, outgoingId, protocolIndex) {
|
||||
// If the result version matches the attempted version, the version is supported
|
||||
const isSuccess = incomingId === outgoingId
|
||||
const indexOf = this.versions.indexOf(protocolIndex)
|
||||
const isSuccess = incomingId === outgoingId;
|
||||
const indexOf = this.versions.indexOf(protocolIndex);
|
||||
|
||||
// Test indexOf to avoid inserting previously recorded protocolIndex values
|
||||
if (isSuccess && indexOf < 0) {
|
||||
this.versions.push(protocolIndex)
|
||||
this.versions.push(protocolIndex);
|
||||
|
||||
// Sort versions in ascending order
|
||||
// This matches protocol ids to Minecraft versions release order
|
||||
this.versions.sort((a, b) => a - b)
|
||||
this.versions.sort((a, b) => a - b);
|
||||
|
||||
return true
|
||||
return true;
|
||||
} else if (!isSuccess && indexOf >= 0) {
|
||||
this.versions.splice(indexOf, 1)
|
||||
return true
|
||||
this.versions.splice(indexOf, 1);
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
getNextProtocolVersion () {
|
||||
getNextProtocolVersion() {
|
||||
// Minecraft Bedrock Edition does not have protocol versions
|
||||
if (this.data.type === 'PE') {
|
||||
if (this.data.type === "PE") {
|
||||
return {
|
||||
protocolId: 0,
|
||||
protocolIndex: 0
|
||||
protocolIndex: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
const protocolVersions = minecraftVersions[this.data.type]
|
||||
if (typeof this._nextProtocolIndex === 'undefined' || this._nextProtocolIndex + 1 >= protocolVersions.length) {
|
||||
this._nextProtocolIndex = 0
|
||||
const protocolVersions = minecraftVersions[this.data.type];
|
||||
if (
|
||||
typeof this._nextProtocolIndex === "undefined" ||
|
||||
this._nextProtocolIndex + 1 >= protocolVersions.length
|
||||
) {
|
||||
this._nextProtocolIndex = 0;
|
||||
} else {
|
||||
this._nextProtocolIndex++
|
||||
this._nextProtocolIndex++;
|
||||
}
|
||||
return {
|
||||
protocolId: protocolVersions[this._nextProtocolIndex].protocolId,
|
||||
protocolIndex: this._nextProtocolIndex
|
||||
}
|
||||
protocolIndex: this._nextProtocolIndex,
|
||||
};
|
||||
}
|
||||
|
||||
filterError (err) {
|
||||
let message = 'Unknown error'
|
||||
filterError(err) {
|
||||
let message = "Unknown error";
|
||||
|
||||
// Attempt to match to the first possible value
|
||||
for (const key of ['message', 'description', 'errno']) {
|
||||
for (const key of ["message", "description", "errno"]) {
|
||||
if (err[key]) {
|
||||
message = err[key]
|
||||
break
|
||||
message = err[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim the message if too long
|
||||
if (message.length > 28) {
|
||||
message = message.substring(0, 28) + '...'
|
||||
message = message.substring(0, 28) + "...";
|
||||
}
|
||||
|
||||
return {
|
||||
message: message
|
||||
}
|
||||
message: message,
|
||||
};
|
||||
}
|
||||
|
||||
getPublicData () {
|
||||
getPublicData() {
|
||||
// Return a custom object instead of data directly to avoid data leakage
|
||||
return {
|
||||
name: this.data.name,
|
||||
ip: this.data.ip,
|
||||
type: this.data.type,
|
||||
color: this.data.color
|
||||
}
|
||||
color: this.data.color,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ServerRegistration
|
||||
module.exports = ServerRegistration;
|
||||
|
102
lib/time.js
102
lib/time.js
@ -1,96 +1,112 @@
|
||||
const config = require('../config.json')
|
||||
const config = require("../config.json");
|
||||
|
||||
const GRAPH_UPDATE_TIME_GAP = 60 * 1000 // 60 seconds
|
||||
const GRAPH_UPDATE_TIME_GAP = 60 * 1000; // 60 seconds
|
||||
|
||||
class TimeTracker {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._serverGraphPoints = []
|
||||
this._graphPoints = []
|
||||
constructor(app) {
|
||||
this._app = app;
|
||||
this._serverGraphPoints = [];
|
||||
this._graphPoints = [];
|
||||
}
|
||||
|
||||
newPointTimestamp () {
|
||||
const timestamp = TimeTracker.getEpochMillis()
|
||||
newPointTimestamp() {
|
||||
const timestamp = TimeTracker.getEpochMillis();
|
||||
|
||||
TimeTracker.pushAndShift(this._serverGraphPoints, timestamp, TimeTracker.getMaxServerGraphDataLength())
|
||||
TimeTracker.pushAndShift(
|
||||
this._serverGraphPoints,
|
||||
timestamp,
|
||||
TimeTracker.getMaxServerGraphDataLength()
|
||||
);
|
||||
|
||||
// Flag each group as history graph additions each minute
|
||||
// This is sent to the frontend for graph updates
|
||||
const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP)
|
||||
const updateHistoryGraph =
|
||||
config.logToDatabase &&
|
||||
(!this._lastHistoryGraphUpdate ||
|
||||
timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP);
|
||||
|
||||
if (updateHistoryGraph) {
|
||||
this._lastHistoryGraphUpdate = timestamp
|
||||
this._lastHistoryGraphUpdate = timestamp;
|
||||
|
||||
// Push into timestamps array to update backend state
|
||||
TimeTracker.pushAndShift(this._graphPoints, timestamp, TimeTracker.getMaxGraphDataLength())
|
||||
TimeTracker.pushAndShift(
|
||||
this._graphPoints,
|
||||
timestamp,
|
||||
TimeTracker.getMaxGraphDataLength()
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
updateHistoryGraph
|
||||
}
|
||||
updateHistoryGraph,
|
||||
};
|
||||
}
|
||||
|
||||
loadGraphPoints (startTime, timestamps) {
|
||||
loadGraphPoints(startTime, timestamps) {
|
||||
// This is a copy of ServerRegistration#loadGraphPoints
|
||||
// timestamps contains original timestamp data and needs to be filtered into minutes
|
||||
this._graphPoints = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => timestamps[i])
|
||||
this._graphPoints = TimeTracker.everyN(
|
||||
timestamps,
|
||||
startTime,
|
||||
GRAPH_UPDATE_TIME_GAP,
|
||||
(i) => timestamps[i]
|
||||
);
|
||||
}
|
||||
|
||||
getGraphPointAt (i) {
|
||||
return TimeTracker.toSeconds(this._graphPoints[i])
|
||||
getGraphPointAt(i) {
|
||||
return TimeTracker.toSeconds(this._graphPoints[i]);
|
||||
}
|
||||
|
||||
getServerGraphPoints () {
|
||||
return this._serverGraphPoints.map(TimeTracker.toSeconds)
|
||||
getServerGraphPoints() {
|
||||
return this._serverGraphPoints.map(TimeTracker.toSeconds);
|
||||
}
|
||||
|
||||
getGraphPoints () {
|
||||
return this._graphPoints.map(TimeTracker.toSeconds)
|
||||
getGraphPoints() {
|
||||
return this._graphPoints.map(TimeTracker.toSeconds);
|
||||
}
|
||||
|
||||
static toSeconds = (timestamp) => {
|
||||
return Math.floor(timestamp / 1000)
|
||||
return Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
static getEpochMillis() {
|
||||
return new Date().getTime();
|
||||
}
|
||||
|
||||
static getEpochMillis () {
|
||||
return new Date().getTime()
|
||||
static getMaxServerGraphDataLength() {
|
||||
return Math.ceil(config.serverGraphDuration / config.rates.pingAll);
|
||||
}
|
||||
|
||||
static getMaxServerGraphDataLength () {
|
||||
return Math.ceil(config.serverGraphDuration / config.rates.pingAll)
|
||||
static getMaxGraphDataLength() {
|
||||
return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP);
|
||||
}
|
||||
|
||||
static getMaxGraphDataLength () {
|
||||
return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP)
|
||||
}
|
||||
|
||||
static everyN (array, start, diff, adapter) {
|
||||
const selected = []
|
||||
let lastPoint = start
|
||||
static everyN(array, start, diff, adapter) {
|
||||
const selected = [];
|
||||
let lastPoint = start;
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const point = array[i]
|
||||
const point = array[i];
|
||||
|
||||
if (point - lastPoint >= diff) {
|
||||
lastPoint = point
|
||||
selected.push(adapter(i))
|
||||
lastPoint = point;
|
||||
selected.push(adapter(i));
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
return selected;
|
||||
}
|
||||
|
||||
static pushAndShift (array, value, maxLength) {
|
||||
array.push(value)
|
||||
static pushAndShift(array, value, maxLength) {
|
||||
array.push(value);
|
||||
|
||||
if (array.length > maxLength) {
|
||||
array.splice(0, array.length - maxLength)
|
||||
array.splice(0, array.length - maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GRAPH_UPDATE_TIME_GAP,
|
||||
TimeTracker
|
||||
}
|
||||
TimeTracker,
|
||||
};
|
||||
|
10
lib/util.js
10
lib/util.js
@ -1,11 +1,11 @@
|
||||
function getPlayerCountOrNull (resp) {
|
||||
function getPlayerCountOrNull(resp) {
|
||||
if (resp) {
|
||||
return resp.players.online
|
||||
return resp.players.online;
|
||||
} else {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlayerCountOrNull
|
||||
}
|
||||
getPlayerCountOrNull,
|
||||
};
|
||||
|
28
lib/utils/timeUtils.js
Normal file
28
lib/utils/timeUtils.js
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
204
servers.json
204
servers.json
@ -1,207 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "Dracarys",
|
||||
"ip": "dracarys.pro",
|
||||
"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",
|
||||
"name": "WildPrison",
|
||||
"ip": "wildprison.net",
|
||||
"type": "PC"
|
||||
}
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user