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