Minetrack 5 (#143)

* remove unused #getServer methods, inline #roundToPoint

* replace #safeName regex with incremental ids

* remove legacy #setInterval based #updateMojangServices handling

* add Tooltip class, move faviconSize to css instead of js

* move server id assignment to ServerRegistry

* move printPort logic to formatMinecraftServerAddress, add MINECRAFT_DEFAULT_PORTS

* simplify ping tracking

* rework perc-bar tooltip to not use mousemove event

* begin moving graphing logic to GraphDisplayManager

* begin merge graph point tracking into graphDisplayManager

* centralizing graphing logic into GraphDisplayManager

* properly reset GraphDisplayManager when handling disconnects

* move individual server graph data into ServerGraph class

* constantly run sortServers loop to simplify logic

* inline #updateMojangServices method

* resize performance improvements

* remove legacy bootTime refresh behavior, require manual user refresh

* move class defs to core.js

* remove unused #isGraphDataVisible arg

* remove #toggleControlsDrawer

* dont call #updatePercentageBar in #updateServerStatus calls

* centralize caption handling

* inline #msToTime

* remove hackish seconds handling for timestamps

* reduce #forEach calls with filter/map

* safely fallback to errorMessage if errno/description does not match

* Add /images/missing_favicon.png path instead of putting base64 in js

* remove debug

* cleanup mojang status handling

* move historyPlot instance into GraphDisplayManager

* cleanup checkbox html generation

* cleanup #updateServerStatus

* fix up tooltip styling

* move jquery code out of core.js

* fix add server race condition when initially pinging servers

* send error.placeholder=true for pending pings so the frontend can discard later

* filter placeholder pings sent by the backend

* del assets/images/logo_2014.png

* move graph code into graph.js

* merge pingTracker into ServerRegistry+ServerGraph

* remove todos

* simplify getVisibleGraphData

* fix potential sortServers race condition when adding

* use #show instead of #fadeIn(0)

* remove publicConfig.json, send over socket

* update docs/CHANGELOG.md

* getOrAssign -> getOrCreateId

* dont delete graph controls when disconnected

* early work cleaning up HTML+CSS structures

* cleanup server css elements

* cleanup graph control css elements

* move base CSS color values into @media(prefers-color-scheme: light)

* move CSS magic colors to vars

* reduce duplicated CSS color rules

* inline body text color CSS

* WIP replacing jQuery calls with vanilla JS

* WIP replacing jQuery calls with vanilla JS

* replace getElementsByClass with querySelectorAll

* typeMarker -> serverTypeHTML

* use jQuery slim for remaining flot.js dependency

* merge setAllGraphVisibility into GraphDisplayManager

* break apart element update and redraw logic

* add eslint + parcel bundler

* auto lint assets/js when building

* statically serve favicons/ for faviconOverrides outside of dist/

* only send favicons when changed

* move faviconOverride behavior into entry in servers.json

* add warning to backend server files

* remove .server-favicon-missing class

* add Minetrack 5 migration guide

* add npm run build step to install.sh

* adjust package.json version to 5.0.0

* remove js references from index.html

* move logic and behavior out of site.js

* cleanup ServerRegistry methods

* prevent multiple history graph redraws

* add comments

* cleanup #addServer usage, move to App

* move graph control bindings into GraphDisplayManager

* site.js -> main.js, core.js -> servers.js

* move Tooltip/Caption into util.js

* spacing tweak

* format index.html

* ensure the frontend does not handling updateHistoryGraph events

* prevent versions/record updates if the same value

* avoid empty percbar updates, ensure versions are sorted

* only include main.js ref in index.html

* serve minified copy of font awesome directly

* bundle icons.css into main.css, remove Open Sans 400

* add new SVG logo

* update docs/CHANGELOG.md

* new design, server version grouping

* remove start.sh call from install.sh

* move graph controls into header with new button

* move #handleSettingsToggle back to graph

* fix legacy code behavior of currentVersionIndex applying globally

* fix header text color in light mode

* fix mojang status text color in light mode

* fix toggle settings and checkbox colors

* tweak button hover color

* tweak button hover color

* add new status-overlay to avoid complicated DOM management during loading

* fix initial graph rendering bug

* add comments

* update default graph tick sizes

* prevent #tooltip from overflowing page

* remove localhost spec

* prevent minor connection errors from reshuffling layout

* update CHANGELOG.md

* add message/button for manually loading historical graph on mobile devices

* send isGraphVisible to frontend to prevent alert if logToDatabase: false

* send timestamp data with record

* update docs/CHANGELOG.md

* remove clock icon

* remove 24h peak timestamp

* Only check favicon if present

* safely handle undefined/empty knownVersions in #formatMinecraftVersions

* merge config.versions and minecraft.json into minecraft_versions.json, simplify index matching behavior

* remove localhost url in socket.io config

* stub methods/linkage for FocusManager

* add #isObjectEqual hack, add event proxying to FocusManager

* wip extended stats box

* remove server-type badging

* tweak mojang unstable color

* serve socket.io-client using parcel

* fix incorrect mojang status colors

* remove legacy capitalization design

* redesign focus boxes

* update docs/CHANGELOG.md

* remove localhost ref

* color clock icon

* use background-color for hover effect, remove unused var

* improve stats focus box icons

* change mojang sessions icon to globe

* Add favorites system

* remove focus boxes

* update docs/CHANGELOG.md

* remove focus icons from font

* simplify graph related event binding

* Add Sort By button

* store current sortOption in localStorage

* update docs/CHANGELOG.md

* move magic 0 sortOption to SORT_OPTION_INDEX_DEFAULT

* remove localhost ref

* merge #settings-toggle, #sort-by and .mojang-status CSS

* remove .focus-box CSS

* use sortedServerIds for _lastSortedServers

* tweak --color-blue

* new missing_favicon design to match logo

* edit footer CSS/text, remove github icon

* replace player count diff counter with GROWTH sort option

* italize non-default sort options

* add Only Favorites button to auto sync favorites to the visible graph data

* add icons to graph control buttons

* update docs/CHANGELOG.md

* use * to denote non-default sort option instead

* remove localhost url in socket.io config

* add value highlighting to make sort by easier to read

* remove last remaining uppercase text

* remove serverTypesVisible from config.json

* simplify header CSS, fix spacing with logToDatabase=false

* fix inverted text color on highlighted values

* remove localhost url in socket.io config

* break header into rows on mobile devices

Co-authored-by: Hugo Manrique <contact@hugmanrique.me>
This commit is contained in:
Nick Krecklow 2020-04-19 19:27:59 -05:00 committed by GitHub
parent 8c5e25b259
commit f875361bc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 10304 additions and 1330 deletions

5
.babelrc Normal file

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

20
.eslintrc.json Normal file

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

2
.gitignore vendored

@ -4,3 +4,5 @@ minetrack.log
database.sql database.sql
database.sql-journal database.sql-journal
.DS_Store .DS_Store
dist/
.cache/

@ -2,6 +2,9 @@
Minetrack is a Minecraft PC/PE server tracker that lets you focus on what's happening *now*. Minetrack is a Minecraft PC/PE server tracker that lets you focus on what's happening *now*.
Built to be lightweight and durable, you can easily adapt it to monitor BungeeCord or server instances. Built to be lightweight and durable, you can easily adapt it to monitor BungeeCord or server instances.
#### Migrating to Minetrack 5
See our Minetrack 5 [migration guide](docs/MIGRATING.md).
#### This project is not actively maintained! #### This project is not actively maintained!
This project and the offical website are not actively maintained anymore, but you are welcome to run your own instances of Minetrack. This project and the offical website are not actively maintained anymore, but you are welcome to run your own instances of Minetrack.
I will however review and accept pull-requests, so please share any improvements you are making so everybody can benefit from them. I will however review and accept pull-requests, so please share any improvements you are making so everybody can benefit from them.

175
app.js

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var server = require('./lib/server'); var server = require('./lib/server');
var ping = require('./lib/ping'); var ping = require('./lib/ping');
var logger = require('./lib/logger'); var logger = require('./lib/logger');
@ -7,22 +12,44 @@ var db = require('./lib/database');
var config = require('./config.json'); var config = require('./config.json');
var servers = require('./servers.json'); var servers = require('./servers.json');
var minecraftVersions = require('./minecraft_versions.json');
var networkHistory = []; var networkHistory = [];
var connectedClients = 0; var connectedClients = 0;
var currentVersionIndex = {
'PC': 0,
'PE': 0
};
var networkVersions = []; var networkVersions = [];
const lastFavicons = [];
var graphData = []; var graphData = [];
var highestPlayerCount = {}; var highestPlayerCount = {};
var lastGraphPush = []; var lastGraphPush = [];
var graphPeaks = {}; var graphPeaks = {};
const serverProtocolVersionIndexes = []
function getNextProtocolVersion (server) {
// Minecraft Bedrock Edition does not have protocol versions
if (server.type === 'PE') {
return {
protocolId: 0,
protocolIndex: 0
}
}
const protocolVersions = minecraftVersions[server.type]
let nextProtocolVersion = serverProtocolVersionIndexes[server.name]
if (typeof nextProtocolVersion === 'undefined' || nextProtocolVersion + 1 >= protocolVersions.length) {
nextProtocolVersion = 0
} else {
nextProtocolVersion++
}
serverProtocolVersionIndexes[server.name] = nextProtocolVersion
return {
protocolId: protocolVersions[nextProtocolVersion].protocolId,
protocolIndex: nextProtocolVersion
}
}
function pingAll() { function pingAll() {
for (var i = 0; i < servers.length; i++) { for (var i = 0; i < servers.length; i++) {
// Make sure we lock our scope. // Make sure we lock our scope.
@ -32,7 +59,7 @@ function pingAll() {
network.color = util.stringToColor(network.name); network.color = util.stringToColor(network.name);
} }
var attemptedVersion = config.versions[network.type][currentVersionIndex[network.type]]; const attemptedVersion = getNextProtocolVersion(network)
ping.ping(network.ip, network.port, network.type, config.rates.connectTimeout, function(err, res) { ping.ping(network.ip, network.port, network.type, config.rates.connectTimeout, function(err, res) {
// Handle our ping results, if it succeeded. // Handle our ping results, if it succeeded.
if (err) { if (err) {
@ -40,27 +67,14 @@ function pingAll() {
} }
// If we have favicon override specified, use it. // If we have favicon override specified, use it.
if (res && config.faviconOverride && config.faviconOverride[network.name]) { if (network.favicon) {
res.favicon = config.faviconOverride[network.name]; res.favicon = network.favicon
} }
handlePing(network, res, err, attemptedVersion); handlePing(network, res, err, attemptedVersion);
}, attemptedVersion); }, attemptedVersion.protocolId);
})(servers[i]); })(servers[i]);
} }
currentVersionIndex['PC']++;
currentVersionIndex['PE']++;
if (currentVersionIndex['PC'] >= config.versions['PC'].length) {
// Loop around
currentVersionIndex['PC'] = 0;
}
if (currentVersionIndex['PE'] >= config.versions['PE'].length) {
// Loop around
currentVersionIndex['PE'] = 0;
}
} }
// This is where the result of a ping is feed. // This is where the result of a ping is feed.
@ -76,18 +90,31 @@ function handlePing(network, res, err, attemptedVersion) {
networkVersions[network.name] = []; networkVersions[network.name] = [];
} }
const serverVersionHistory = networkVersions[network.name]
// If the result version matches the attempted version, the version is supported // If the result version matches the attempted version, the version is supported
var _networkVersions = networkVersions[network.name]; if (res && res.version !== undefined) {
if (res) { const indexOf = serverVersionHistory.indexOf(attemptedVersion.protocolIndex)
if (res.version == attemptedVersion) {
if (_networkVersions.indexOf(res.version) == -1) { // Test indexOf to avoid inserting previously recorded protocolIndex values
_networkVersions.push(res.version); if (res.version === attemptedVersion.protocolId && indexOf === -1) {
serverVersionHistory.push(attemptedVersion.protocolIndex)
} else if (res.version !== attemptedVersion.protocolId && indexOf >= 0) {
serverVersionHistory.splice(indexOf, 1)
} }
} else { }
// Mismatch, so remove the version from the supported version list
var index = _networkVersions.indexOf(attemptedVersion); const timestamp = util.getCurrentTimeMs()
if (index != -1) {
_networkVersions.splice(index, 1); if (res) {
const recordData = highestPlayerCount[network.ip]
// Validate that we have logToDatabase enabled otherwise in memory pings
// will create a record that's only valid for the runtime duration.
if (config.logToDatabase && (!recordData || res.players.online > recordData.playerCount)) {
highestPlayerCount[network.ip] = {
playerCount: res.players.online,
timestamp: timestamp
} }
} }
} }
@ -96,20 +123,25 @@ function handlePing(network, res, err, attemptedVersion) {
var networkSnapshot = { var networkSnapshot = {
info: { info: {
name: network.name, name: network.name,
timestamp: util.getCurrentTimeMs(), timestamp: timestamp,
type: network.type type: network.type
}, },
versions: _networkVersions, versions: serverVersionHistory,
record: highestPlayerCount[network.ip] recordData: highestPlayerCount[network.ip]
}; };
if (res) { if (res) {
networkSnapshot.result = res; networkSnapshot.result = res;
// Validate that we have logToDatabase enabled otherwise in memory pings // Only emit updated favicons
// will create a record that's only valid for the runtime duration. // Favicons will otherwise be explicitly emitted during the handshake process
if (config.logToDatabase && res.players.online > highestPlayerCount[network.ip]) { if (res.favicon) {
highestPlayerCount[network.ip] = res.players.online; const lastFavicon = lastFavicons[network.name]
if (lastFavicon !== res.favicon) {
lastFavicons[network.name] = res.favicon
networkSnapshot.favicon = res.favicon // Send updated favicon directly on object
}
delete res.favicon // Never store favicons in memory outside lastFavicons
} }
} else if (err) { } else if (err) {
networkSnapshot.error = err; networkSnapshot.error = err;
@ -121,18 +153,15 @@ function handlePing(network, res, err, attemptedVersion) {
// Remove our previous data that we don't need anymore. // Remove our previous data that we don't need anymore.
for (var i = 0; i < _networkHistory.length; i++) { for (var i = 0; i < _networkHistory.length; i++) {
delete _networkHistory[i].versions
delete _networkHistory[i].info; delete _networkHistory[i].info;
if (_networkHistory[i].result) {
delete _networkHistory[i].result.favicon;
}
} }
_networkHistory.push({ _networkHistory.push({
error: err, error: err,
result: res, result: res,
versions: _networkVersions, versions: serverVersionHistory,
timestamp: util.getCurrentTimeMs(), timestamp: timestamp,
info: { info: {
ip: network.ip, ip: network.ip,
port: network.port, port: network.port,
@ -148,18 +177,16 @@ function handlePing(network, res, err, attemptedVersion) {
// Log it to the database if needed. // Log it to the database if needed.
if (config.logToDatabase) { if (config.logToDatabase) {
db.log(network.ip, util.getCurrentTimeMs(), res ? res.players.online : 0); db.log(network.ip, timestamp, res ? res.players.online : 0);
} }
// Push it to our graphs.
var timeMs = util.getCurrentTimeMs();
// The same mechanic from trimUselessPings is seen here. // The same mechanic from trimUselessPings is seen here.
// If we dropped the ping, then to avoid destroying the graph, ignore it. // If we dropped the ping, then to avoid destroying the graph, ignore it.
// However if it's been too long since the last successful ping, we'll send it anyways. // However if it's been too long since the last successful ping, we'll send it anyways.
if (config.logToDatabase) { if (config.logToDatabase) {
if (!lastGraphPush[network.ip] || (timeMs - lastGraphPush[network.ip] >= 60 * 1000 && res) || timeMs - lastGraphPush[network.ip] >= 70 * 1000) { if (!lastGraphPush[network.ip] || (timestamp - lastGraphPush[network.ip] >= 60 * 1000 && res) || timestamp - lastGraphPush[network.ip] >= 70 * 1000) {
lastGraphPush[network.ip] = timeMs; lastGraphPush[network.ip] = timestamp;
// Don't have too much data! // Don't have too much data!
util.trimOldPings(graphData); util.trimOldPings(graphData);
@ -168,14 +195,14 @@ function handlePing(network, res, err, attemptedVersion) {
graphData[network.name] = []; graphData[network.name] = [];
} }
graphData[network.name].push([timeMs, res ? res.players.online : 0]); graphData[network.name].push([timestamp, res ? res.players.online : 0]);
// Send the update. // Send the update.
server.io.sockets.emit('updateHistoryGraph', { server.io.sockets.emit('updateHistoryGraph', {
ip: network.ip, ip: network.ip,
name: network.name, name: network.name,
players: (res ? res.players.online : 0), players: (res ? res.players.online : 0),
timestamp: timeMs timestamp: timestamp
}); });
} }
@ -234,10 +261,6 @@ function startServices() {
logger.log('info', '%s connected, total clients: %d', util.getRemoteAddr(client.request), connectedClients); logger.log('info', '%s connected, total clients: %d', util.getRemoteAddr(client.request), connectedClients);
// We send the boot time (also sent in publicConfig.json) to the frontend to validate they have the same config.
// If so, they'll send back "requestListing" event, otherwise they will pull the new config and retry.
client.emit('bootTime', util.getBootTime());
// Attach our listeners. // Attach our listeners.
client.on('disconnect', function() { client.on('disconnect', function() {
connectedClients -= 1; connectedClients -= 1;
@ -257,15 +280,22 @@ function startServices() {
} }
}); });
client.on('requestListing', function() { const minecraftVersionNames = {}
Object.keys(minecraftVersions).forEach(function (key) {
minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name)
})
// Send configuration data for rendering the page
client.emit('setPublicConfig', {
graphDuration: config.graphDuration,
servers: servers,
minecraftVersions: minecraftVersionNames,
isGraphVisible: config.logToDatabase
});
// Send them our previous data, so they have somewhere to start. // Send them our previous data, so they have somewhere to start.
client.emit('updateMojangServices', mojang.toMessage()); client.emit('updateMojangServices', mojang.toMessage());
// Remap our associative array into just an array.
var networkHistoryKeys = Object.keys(networkHistory);
networkHistoryKeys.sort();
// Send each individually, this should look cleaner than waiting for one big array to transfer. // Send each individually, this should look cleaner than waiting for one big array to transfer.
for (var i = 0; i < servers.length; i++) { for (var i = 0; i < servers.length; i++) {
var server = servers[i]; var server = servers[i];
@ -274,7 +304,8 @@ function startServices() {
// This server hasn't been ping'd yet. Send a hacky placeholder. // This server hasn't been ping'd yet. Send a hacky placeholder.
client.emit('add', [[{ client.emit('add', [[{
error: { error: {
description: 'Waiting' description: 'Waiting...',
placeholder: true
}, },
result: null, result: null,
timestamp: util.getCurrentTimeMs(), timestamp: util.getCurrentTimeMs(),
@ -286,13 +317,18 @@ function startServices() {
} }
}]]); }]]);
} else { } else {
client.emit('add', [networkHistory[networkHistoryKeys[i]]]); // Append the lastFavicon to the last ping entry
const serverHistory = networkHistory[server.name];
const lastFavicon = lastFavicons[server.name];
if (lastFavicon) {
serverHistory[serverHistory.length - 1].favicon = lastFavicon
}
client.emit('add', [serverHistory])
} }
} }
client.emit('syncComplete'); client.emit('syncComplete');
}); });
});
startMainLoop(); startMainLoop();
} }
@ -332,10 +368,13 @@ if (config.logToDatabase) {
} }
(function(server) { (function(server) {
db.getTotalRecord(server.ip, function(record) { db.getTotalRecord(server.ip, function(playerCount, timestamp) {
logger.log('info', 'Computed total record %s (%d)', server.ip, record); logger.log('info', 'Computed total record %s (%d) @ %d', server.ip, playerCount, timestamp);
highestPlayerCount[server.ip] = record; highestPlayerCount[server.ip] = {
playerCount: playerCount,
timestamp: timestamp
};
completedQueries += 1; completedQueries += 1;

59
assets/css/icons.css Normal file

@ -0,0 +1,59 @@
@font-face {
font-family: 'icomoon';
src:
url('../fonts/icomoon.ttf?gn52nv') format('truetype'),
url('../fonts/icomoon.woff?gn52nv') format('woff'),
url('../fonts/icomoon.svg?gn52nv#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-star:before {
content: "\f005";
}
.icon-star-o:before {
content: "\f006";
}
.icon-lock:before {
content: "\f023";
}
.icon-eye:before {
content: "\f06e";
}
.icon-eye-slash:before {
content: "\f070";
}
.icon-cogs:before {
content: "\f085";
}
.icon-gears:before {
content: "\f085";
}
.icon-globe:before {
content: "\f0ac";
}
.icon-code:before {
content: "\f121";
}
.icon-sort-amount-desc:before {
content: "\f161";
}
.icon-street-view:before {
content: "\f21d";
}

@ -1,16 +1,62 @@
@import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300,400); @import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300);
@import url(../css/icons.css);
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { :root {
--color-dark-gray: #A3A3A3;
--color-gold: #FFD700;
--color-dark-purple: #6c5ce7;
--color-light-purple: #a29bfe;
--color-dark-blue: #0984e3;
--color-light-blue: #74b9ff;
--theme-color-dark: #3B3738;
--theme-color-light: #EBEBEB;
--border-radius: 1px;
}
@media (prefers-color-scheme: light) {
:root {
--color-purple: var(--color-light-purple);
--color-blue: var(--color-light-blue);
--background-color: var(--theme-color-light);
--text-decoration-color: var(--theme-color-dark);
--text-color: #000;
--text-color-inverted: #FFF;
}
body {
background: #212021; background: #212021;
color: #FFF; color: #FFF;
}
}
@media (prefers-color-scheme: dark) {
:root {
--color-purple: var(--color-dark-purple);
--color-blue: var(--color-dark-blue);
--background-color: var(--theme-color-dark);
--text-decoration-color: var(--theme-color-light);
--text-color: #FFF;
--text-color-inverted: #000;
}
body {
background: #1c1b1c;
color: #FFF;
}
}
body {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
font-size: 18px; font-size: 18px;
font-weight: 300 !important; font-weight: 300;
min-width: 800px;
} }
/* Page layout */ /* Page layout */
@ -20,9 +66,12 @@ html, body {
a { a {
cursor: pointer; cursor: pointer;
color: inherit;
text-decoration: none;
} }
#push { #push {
display: none;
position: relative; position: relative;
min-height: 100%; min-height: 100%;
} }
@ -31,84 +80,145 @@ strong {
font-weight: 700; font-weight: 700;
} }
/* Logo */
.logo-text {
letter-spacing: -3px;
}
/* Header */ /* Header */
#header { header {
background: #EBEBEB;
color: #3B3738;
overflow: auto; overflow: auto;
padding: 20px;
} }
#header-wrapper { header .column-left {
overflow: auto;
min-width: 850px;
}
#header .column {
display: inline-block;
float: left; float: left;
} }
#header .column h1 { header .column-right {
float: right;
}
header .logo-image {
--fixed-logo-image-size: 36px;
width: var(--fixed-logo-image-size);
height: var(--fixed-logo-image-size);
margin-right: 5px;
display: inline-block;
}
header .logo-text {
font-size: 48px;
margin: -6px 0; margin: -6px 0;
display: inline-block;
} }
#header .slogan { header .logo-status {
font-size: 20px; font-size: 21px;
text-align: left;
} }
#header .subslogan { header a {
font-size: 19px; border-bottom: 1px dashed var(--text-decoration-color);
} }
#header a, #footer a { header a:hover {
text-decoration: none;
color: inherit;
border-bottom: 1px dashed #3B3738;
}
#header a:hover, #footer a:hover {
border-bottom: 1px dashed transparent; border-bottom: 1px dashed transparent;
} }
#header > h1 { header .header-button {
font-size: 42px; color: var(--text-color);
width: 83px;
height: 83px;
text-align: center;
line-height: 20px;
font-size: 14px;
margin-left: -5px;
} }
#header > #column-center { header .header-button > span:first-of-type {
width: 1480px; display: block;
margin: 0 auto; margin-top: 10px;
text-align: center; font-size: 22px;
}
header .header-button-group {
display: inline-block;
}
header .header-button-group:first-of-type {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
header .header-button-group:last-of-type {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
header .header-button-single {
display: none;
cursor: pointer;
border-radius: var(--border-radius);
margin-right: 20px;
}
header .header-button-single:hover {
background: var(--background-color) !important;
} }
/* Footer */ /* Footer */
#footer { footer {
font-size: 16px; display: none;
text-transform: uppercase; background: var(--background-color);
background: #EBEBEB; color: var(--text-color);
color: #3B3738; padding: 10px 0 15px 0;
padding: 15px 0; text-align: center;
min-width: 950px;
margin-top: 15px; margin-top: 15px;
} }
#footer a { footer a {
font-weight: 700; border-bottom: 1px dashed var(--text-decoration-color);
border-bottom: none !important;
} }
#footer a:hover { footer a:hover {
border-bottom: 1px dashed #000 !important; border-bottom: 1px dashed transparent;
} }
/* Tagline */ /* Status overly */
#tagline-text { #status-overlay {
padding-top: 20px; padding: 20px 0;
background: var(--background-color);
color: var(--text-color);
border-radius: var(--border-radius);
text-align: center; text-align: center;
height: 150px;
width: 350px;
position: fixed;
top: 50%;
left: 50%;
margin-top: -95px;
margin-left: -175px;
}
#status-overlay .logo-image {
--fixed-logo-image-size: 72px;
width: var(--fixed-logo-image-size);
height: var(--fixed-logo-image-size);
}
/* Floating tooltip */
#tooltip {
display: none;
position: absolute;
padding: 5px 8px;
border-radius: var(--border-radius);
background: var(--background-color);
color: var(--text-color);
z-index: 10000;
} }
/* Server listing */ /* Server listing */
.server-container { #server-list {
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -119,95 +229,123 @@ strong {
padding: 5px 10px; padding: 5px 10px;
margin: 0 5px; margin: 0 5px;
width: 800px; width: 800px;
border: 1px solid transparent;
display: inline-block; display: inline-block;
} }
.version { .server .column {
font-size: 12px; float: left;
} }
/*.server:hover { .server .column-favicon {
background: #282828; width: 80px;
border: 1px solid #444; }
cursor: pointer;
border-radius: 2px;
}*/
.server > .column > img { .server .column-favicon .server-favicon {
border-radius: 2px; --fixed-server-favicon-size: 64px;
width: var(--fixed-server-favicon-size);
height: var(--fixed-server-favicon-size);
border-radius: var(--border-radius);
margin-top: 5px; margin-top: 5px;
} }
.server > .column { .server .column-favicon .server-rank {
float: left; display: block;
width: 64px;
text-align: center;
}
.server .column-status {
width: 282px;
}
.server .column-status .server-name {
display: inline-block; display: inline-block;
} }
.server > .column > .rank { .server .column-status .server-is-favorite {
width: 64px; cursor: pointer;
padding-top: 4px; color: var(--color-gold);
} }
.server > .column > h3 > .type { .server .column-status .server-is-favorite:hover::before {
padding: 1px 5px; content: "\f006";
border-radius: 2px;
border: 1px solid #A09E9E;
font-size: 14px;
margin-bottom: 2px;
} }
.server-meta { .server .column-status .server-is-not-favorite {
font-size: 16px !important; cursor: pointer;
color: var(--background-color);
} }
/* Charts */ .server .column-status .server-is-not-favorite:hover {
.chart { color: var(--color-gold);
}
.server .column-status .server-error {
display: none;
color: #e74c3c;
}
.server .column-status .server-label {
color: var(--color-dark-gray);
font-size: 16px;
display: none;
}
.server .column-status .server-value {
color: var(--color-dark-gray);
font-size: 16px;
}
.server .column-graph {
float: right;
height: 100px; height: 100px;
width: 400px; width: 400px;
margin-right: -3px; margin-right: -3px;
margin-bottom: 5px; margin-bottom: 5px;
} }
#tooltip { /* Highlighted values */
display: none; .server-highlighted-label {
position: absolute; font-size: 18px;
padding: 5px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.65);
z-index: 10000;
} }
/* Existing elements */ .server-highlighted-value {
h3 { font-size: 18px;
text-transform: uppercase; font-weight: 700;
} }
/* Basic classes used randomly */ /* Global stats */
.color-gray { .global-stat {
color: #C4C4C4; font-weight: 700;
} }
.color-dark-gray { /* Sort by */
color: #A3A3A3; #sort-by {
background: var(--color-purple);
} }
.color-red { /* Settings toggle */
color: #e74c3c; #settings-toggle {
background: var(--color-blue);
} }
.text-uppercase { /* Historical graph */
text-transform: uppercase; #big-graph-mobile-load-request {
} background: var(--background-color);
color: var(--text-color);
.text-center-align { padding: 10px 0;
text-align: center; text-align: center;
display: none;
width: 100%;
margin-bottom: 10px;
}
#big-graph-mobile-load-request a {
display: inline-block;
} }
/* The big graph */
#big-graph, #big-graph-controls, #big-graph-checkboxes { #big-graph, #big-graph-controls, #big-graph-checkboxes {
width: 90%; width: 90%;
margin: 15px auto 0 auto;
} }
#big-graph-checkboxes > table { #big-graph-checkboxes > table {
@ -215,119 +353,105 @@ h3 {
} }
#big-graph { #big-graph {
margin-top: 20px; margin: 0 auto;
} }
#big-graph-controls { #big-graph-controls {
margin: 10px auto; margin: 10px auto;
display: none;
} }
#big-graph-controls a { #big-graph-controls .icon-star {
text-decoration: none; color: var(--color-gold);
color: inherit;
text-transform: uppercase;
border-bottom: 1px dashed #FFF;
font-size: 16px;
} }
#big-graph-controls a:hover { #big-graph-controls-drawer {
border-bottom: 1px dashed transparent; background: var(--background-color);
color: var(--text-color);
border-radius: var(--border-radius);
padding-bottom: 10px;
overflow: auto;
display: none;
}
#big-graph-controls-drawer .graph-controls-setall {
text-align: center;
display: block;
margin: 15px 0;
}
#big-graph-checkboxes {
margin: 15px auto 0 auto;
} }
/* Basic elements */ /* Basic elements */
.button { .button {
background: #3498db; background: var(--color-blue);
border-radius: 2px; border-radius: var(--border-radius);
text-shadow: 0 0 0 #000;
width: 85px;
font-size: 16px; font-size: 16px;
padding: 5px 10px; padding: 5px 10px;
margin: 0 auto;
} }
.button:hover { .button:hover {
background: #ecf0f1; background: var(--text-color);
color: #3498db; color: var(--text-color-inverted);
cursor: pointer;
} }
/* Percentage bar */ /* Percentage bar */
#perc-bar { #perc-bar {
height: 35px; height: 35px;
position: relative; position: relative;
overflow-x: hidden;
} }
#perc-bar > .perc-bar-part { #perc-bar .perc-bar-part {
height: 100%; height: 100%;
display: inline-block; display: inline-block;
position: absolute; position: absolute;
transition: 0.1s all;
}
#perc-bar > .perc-bar-part:hover {
opacity: 0.75;
}
/* Mojang Status */
.mojang-status {
width: 85px;
height: 106px;
display: inline-block;
text-align: center;
line-height: 20px;
font-size: 14px;
}
.mojang-status > strong {
text-transform: uppercase;
}
.mojang-status > i {
margin-top: 20px;
font-size: 22px;
} }
/* Mojang status colors */ /* Mojang status colors */
.mojang-status-online { @media (prefers-color-scheme: light) {
background: #87D37C;
}
.mojang-status-unstable {
background: #f1c40f;
}
.mojang-status-offline {
background: #DE5749;
}
/* Dark color scheme overrides */
@media (prefers-color-scheme: dark) {
body {
background: #1c1b1c;
}
#header, #footer {
background: #3B3738;
color: #FFF;
}
#footer a, #header a:hover {
border-bottom: 1px dashed transparent !important;
}
#footer a:hover, #header a {
border-bottom: 1px dashed #EBEBEB !important;
}
.mojang-status-online { .mojang-status-online {
background: #6b9963; background: #87D37C;
} }
.mojang-status-unstable { .mojang-status-unstable {
background: #a87b4b; background: #f1c40f;
}
.mojang-status-offline {
background: #DE5749;
}
}
@media (prefers-color-scheme: dark) {
.mojang-status-online {
background: #66aa5a;
}
.mojang-status-unstable {
background: #cc8a4f;
} }
.mojang-status-offline { .mojang-status-offline {
background: #A6453B; background: #A6453B;
} }
} }
/* Header rows */
@media only screen and (max-width: 1050px) {
header {
padding: 0 !important;
}
.header-possible-row-break {
padding-top: 20px;
width: 100%;
text-align: center;
}
.header-possible-row-break:last-of-type {
margin-bottom: 20px;
}
}

20
assets/fonts/icomoon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/fonts/icomoon.ttf Normal file

Binary file not shown.

BIN
assets/fonts/icomoon.woff Normal file

Binary file not shown.

@ -2,98 +2,97 @@
<html> <html>
<head> <head>
<link rel="stylesheet" type="text/css" href="css/main.css"> <link rel="stylesheet" type="text/css" href="../css/main.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.6.3/css/font-awesome.min.css">
<link rel="icon" type="image/png" href="/images/compass.png"> <link rel="icon" type="image/svg+xml" href="../images/logo.svg">
<meta charset="UTF-8">
<title>Minetrack</title> <title>Minetrack</title>
</head> </head>
<body> <body>
<div id="tooltip"></div> <div id="tooltip"></div>
<div id="status-overlay">
<img class="logo-image" src="../images/logo.svg">
<h1 class="logo-text">Minetrack</h1>
<div id="status-text">Connecting...</div>
</div>
<div id="push"> <div id="push">
<div id="header">
<div id="header-wrapper">
<div class="column" style="padding: 20px;">
<img src="/images/compass.png" width="28" height="28" style="display: inline-block; margin-right: 5px;">
<h1 style="display: inline-block;" class="text-uppercase">Minetrack</h1>
<p class="subslogan text-uppercase">Watching <span id="stat_totalPlayers">0</span> players on <span id="stat_networks">0</span> networks.</p>
</div>
<div id="mojang-status-list" class="column" style="float: right;">
<div class="mojang-status" title="Sessions" id="mojang-status_Sessions"><i class="fa fa-gamepad"></i><br /><strong>Sessions</strong><p class="mojang-status-text" id="mojang-status-text_Sessions">...</p></div><div class="mojang-status" title="Skins" id="mojang-status_Skins"><i class="fa fa-user"></i><br /><strong>Skins</strong><p class="mojang-status-text" id="mojang-status-text_Skins">...</p></div><div class="mojang-status" title="Auth" id="mojang-status_Auth"><i class="fa fa-key"></i><br /><strong>Auth</strong><p class="mojang-status-text" id="mojang-status-text_Auth">...</p></div><div class="mojang-status" title="API" id="mojang-status_API"><i class="fa fa-wrench"></i><br /><strong>API</strong><p class="mojang-status-text" id="mojang-status-text_API">...</p></div>
</div>
</div>
</div>
<div id="perc-bar"></div> <div id="perc-bar"></div>
<div id="tagline-text">Connecting...</div> <header>
<div class="header-possible-row-break column-left">
<img class="logo-image" src="../images/logo.svg">
<h1 class="logo-text">Minetrack</h1>
<p class="logo-status">Counting <span class="global-stat" id="stat_totalPlayers">0</span> players on <span class="global-stat" id="stat_networks">0</span> Minecraft servers.</p>
</div>
<div class="header-possible-row-break column-right">
<div id="sort-by" class="header-button header-button-single"><span class="icon-sort-amount-desc"></span> Sort By<br><strong id="sort-by-text">...</strong></div>
<div id="settings-toggle" class="header-button header-button-single"><span class="icon-gears"></span> Graph Controls</div>
<div class="header-button header-button-group" title="Sessions" id="mojang-status_Sessions">
<span class="icon-globe"></span><strong>Sessions</strong>
<p class="mojang-status-text" id="mojang-status-text_Sessions">...</p>
</div>
<div class="header-button header-button-group" title="Skins" id="mojang-status_Skins">
<span class="icon-street-view"></span><strong>Skins</strong>
<p class="mojang-status-text" id="mojang-status-text_Skins">...</p>
</div>
<div class="header-button header-button-group" title="Auth" id="mojang-status_Auth">
<span class="icon-lock"></span><strong>Auth</strong>
<p class="mojang-status-text" id="mojang-status-text_Auth">...</p>
</div>
<div class="header-button header-button-group" title="API" id="mojang-status_API">
<span class="icon-code"></span><strong>API</strong>
<p class="mojang-status-text" id="mojang-status-text_API">...</p>
</div>
</div>
</header>
<div id="big-graph-mobile-load-request">
<strong>On a mobile device?</strong>
<p>Minetrack has skipped automatically loading the historical graph to help save data and power.</p>
<br>
<a id="big-graph-mobile-load-request-button" class="button">Load Historical Graph</a>
</div>
<div id="big-graph"></div> <div id="big-graph"></div>
<div id="big-graph-controls" style="display: none;"> <div id="big-graph-controls">
<div id="big-graph-controls-drawer">
<span style="text-align: center; display: block;">
<a onclick="toggleControlsDrawer();">Click to toggle graph controls</a>
</span>
<div id="big-graph-controls-drawer" style="display: none;">
<div id="big-graph-checkboxes"></div> <div id="big-graph-checkboxes"></div>
<br /> <span class="graph-controls-setall">
<a minetrack-show-type="all" class="button graph-controls-show"><span class="icon-eye"></span> Show All</a>
<span style="text-align: center; display: block; margin-bottom: 15px;"> <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 onclick="setAllGraphVisibility(true);" class="button">Show All</span>
<span onclick="setAllGraphVisibility(false);" class="button">Hide All</span>
</span> </span>
</div>
</div>
<div id="server-list"></div>
</div> </div>
</div> <footer id="footer">
<span class="icon-code"></span> Powered by open source software - <a href="https://github.com/Cryptkeeper/Minetrack">make it your own!</a>
</footer>
<div id="server-container-list" class="server-container"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.slim.min.js"></script>
</div>
<div id="footer">
<span style="padding-left: 20px;">Website powered by <a href="https://github.com/Cryptkeeper/Minetrack"><i class="fa fa-github"></i> Minetrack</a>.</span>
</div>
<!-- External JS assets -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flot/0.8.3/jquery.flot.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/flot/0.8.3/jquery.flot.min.js"></script>
<!-- Internal JS assets --> <script src="../js/main.js" defer></script>
<script src="js/util.js"></script>
<script src="js/graph.js"></script>
<script src="js/site.js"></script>
<script src="publicConfig.json"></script> </body>
</body>
</html> </html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

8
assets/images/logo.svg Normal file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" width="500" height="500" xmlns="http://www.w3.org/2000/svg">
<circle style="fill: rgb(85, 218, 149);" cx="250" cy="250" r="250"/>
<path d="M 0 250 C 0 111.929 111.929 0 250 0 C 369.688 0 469.731 84.108 494.25 196.443 L 300.624 389.846 L 200.592 290.249 L 68.654 422.087 C 26.106 377.264 0 316.681 0 250 Z" style="fill: rgb(178, 255, 161);"/>
<path d="M 191.114 130.467 L 217.723 185.377 L 164.504 185.377 L 191.114 130.467 Z" style="fill: rgb(41, 41, 41);" transform="matrix(-1, 0.000057, -0.000057, -1, 391.12207, 407.396851)"/>
<rect x="100" y="81.861" width="200" height="147.724" style="fill: rgb(41, 41, 41);" rx="7.498" ry="7.498"/>
<path d="M 185.523 118.251 L 185.523 200.341 L 163.003 200.341 L 163.003 156.881 C 163.003 151.601 163.116 146.771 163.343 142.391 C 161.956 144.078 160.233 145.838 158.173 147.671 L 148.853 155.361 L 137.343 141.211 L 165.533 118.251 L 185.523 118.251 ZM 263.346 159.401 C 263.346 173.928 260.912 184.571 256.046 191.331 C 251.179 198.084 243.729 201.461 233.696 201.461 C 223.889 201.461 216.496 197.934 211.516 190.881 C 206.536 183.821 204.046 173.328 204.046 159.401 C 204.046 144.808 206.479 134.104 211.346 127.291 C 216.212 120.478 223.662 117.071 233.696 117.071 C 243.469 117.071 250.852 120.618 255.846 127.711 C 260.846 134.804 263.346 145.368 263.346 159.401 Z M 226.116 159.401 C 226.116 168.688 226.696 175.071 227.856 178.551 C 229.016 182.038 230.962 183.781 233.696 183.781 C 236.469 183.781 238.426 181.981 239.566 178.381 C 240.706 174.788 241.276 168.461 241.276 159.401 C 241.276 150.308 240.696 143.938 239.536 140.291 C 238.376 136.638 236.429 134.811 233.696 134.811 C 230.962 134.811 229.016 136.571 227.856 140.091 C 226.696 143.611 226.116 150.048 226.116 159.401 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill: rgb(255, 255, 255); white-space: pre;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" width="500" height="500" xmlns="http://www.w3.org/2000/svg">
<circle style="fill: rgb(227, 106, 87);" cx="250" cy="250" r="250"/>
<path d="M 5.7 172.087 C 5.7 310.158 117.629 422.087 255.7 422.087 C 375.388 422.087 475.431 337.979 499.95 225.644 L 306.324 32.241 L 206.292 131.838 L 74.354 0 C 31.806 44.823 5.7 105.406 5.7 172.087 Z" style="fill: rgb(222, 222, 222);" transform="matrix(-1, 0, 0, -1, 505.700409, 422.087006)"/>
<path d="M 191.114 130.467 L 217.723 185.377 L 164.504 185.377 L 191.114 130.467 Z" style="fill: rgb(41, 41, 41);" transform="matrix(-1, 0.000057, -0.000057, -1, 491.12207, 407.396851)"/>
<rect x="200" y="81.861" width="200" height="147.724" style="fill: rgb(41, 41, 41);" rx="7.498" ry="7.498"/>
<path d="M 304.775 171.28 L 287.535 171.28 L 287.535 166.45 C 287.535 162.777 288.285 159.677 289.785 157.15 C 291.285 154.623 293.942 152.163 297.755 149.77 C 300.788 147.863 302.972 146.14 304.305 144.6 C 305.632 143.067 306.295 141.307 306.295 139.32 C 306.295 137.753 305.585 136.51 304.165 135.59 C 302.738 134.67 300.885 134.21 298.605 134.21 C 292.952 134.21 286.345 136.213 278.785 140.22 L 270.975 124.95 C 280.295 119.637 290.028 116.98 300.175 116.98 C 308.522 116.98 315.072 118.813 319.825 122.48 C 324.578 126.147 326.955 131.143 326.955 137.47 C 326.955 142.003 325.898 145.927 323.785 149.24 C 321.672 152.553 318.295 155.687 313.655 158.64 C 309.722 161.187 307.258 163.05 306.265 164.23 C 305.272 165.41 304.775 166.803 304.775 168.41 L 304.775 171.28 Z M 285.125 190.93 C 285.125 187.483 286.125 184.827 288.125 182.96 C 290.132 181.087 293.082 180.15 296.975 180.15 C 300.715 180.15 303.588 181.093 305.595 182.98 C 307.595 184.873 308.595 187.523 308.595 190.93 C 308.595 194.337 307.558 196.977 305.485 198.85 C 303.405 200.717 300.568 201.65 296.975 201.65 C 293.268 201.65 290.365 200.723 288.265 198.87 C 286.172 197.017 285.125 194.37 285.125 190.93 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill: rgb(255, 255, 255); white-space: pre;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

140
assets/js/app.js Normal file

@ -0,0 +1,140 @@
import { ServerRegistry } from './servers'
import { SortController } from './sort'
import { GraphDisplayManager } from './graph'
import { MojangUpdater } from './mojang'
import { PercentageBar } from './percbar'
import { FavoritesManager } from './favorites'
import { Tooltip, Caption, formatNumber } from './util'
export class App {
publicConfig
constructor () {
this.tooltip = new Tooltip()
this.caption = new Caption()
this.serverRegistry = new ServerRegistry(this)
this.sortController = new SortController(this)
this.graphDisplayManager = new GraphDisplayManager(this)
this.mojangUpdater = new MojangUpdater()
this.percentageBar = new PercentageBar(this)
this.favoritesManager = new FavoritesManager(this)
this._taskIds = []
}
setPageReady (isReady) {
document.getElementById('push').style.display = isReady ? 'block' : 'none'
document.getElementById('footer').style.display = isReady ? 'block' : 'none'
document.getElementById('status-overlay').style.display = isReady ? 'none' : 'block'
}
setPublicConfig (publicConfig) {
this.publicConfig = publicConfig
this.serverRegistry.assignServers(publicConfig.servers)
// Start repeating frontend tasks once it has received enough data to be considered active
// This simplifies management logic at the cost of each task needing to safely handle empty data
this.initTasks()
}
handleSyncComplete () {
this.caption.hide()
// Load favorites since all servers are registered
this.favoritesManager.loadLocalStorage()
// Run a single bulk server sort instead of per-add event since there may be multiple
this.sortController.show()
this.percentageBar.redraw()
}
initTasks () {
this._taskIds.push(setInterval(this.sortController.sortServers, 5000))
this._taskIds.push(setInterval(this.updateGlobalStats, 1000))
this._taskIds.push(setInterval(this.percentageBar.redraw, 1000))
}
handleDisconnect () {
this.tooltip.hide()
// Reset individual tracker elements to flush any held data
this.serverRegistry.reset()
this.sortController.reset()
this.graphDisplayManager.reset()
this.mojangUpdater.reset()
this.percentageBar.reset()
// Undefine publicConfig, resynced during the connection handshake
this.publicConfig = undefined
// Clear all task ids, if any
this._taskIds.forEach(clearInterval)
this._taskIds = []
// Reset hidden values created by #updateGlobalStats
this._lastTotalPlayerCount = undefined
this._lastServerRegistrationCount = undefined
// Reset modified DOM structures
document.getElementById('stat_totalPlayers').innerText = 0
document.getElementById('stat_networks').innerText = 0
// Modify page state to display loading overlay
this.caption.set('Lost connection!')
this.setPageReady(false)
}
getTotalPlayerCount () {
return this.serverRegistry.getServerRegistrations()
.map(serverRegistration => serverRegistration.playerCount)
.reduce((sum, current) => sum + current, 0)
}
addServer = (pings) => {
// Even if the backend has never pinged the server, the frontend is promised a placeholder object.
// result = undefined
// error = defined with "Waiting" description
// info = safely defined with configured data
const latestPing = pings[pings.length - 1]
const serverRegistration = this.serverRegistry.createServerRegistration(latestPing.info.name)
serverRegistration.initServerStatus(latestPing)
// Push the historical data into the graph
// This will trim and format the data so it is ready for the graph to render once init
serverRegistration.addGraphPoints(pings)
// Create the plot instance internally with the restructured and cleaned data
serverRegistration.buildPlotInstance()
// Handle the last known state (if any) as an incoming update
// This triggers the main update pipeline and enables centralized update handling
serverRegistration.updateServerStatus(latestPing, true, this.publicConfig.minecraftVersions)
// Allow the ServerRegistration to bind any DOM events with app instance context
serverRegistration.initEventListeners()
}
updateGlobalStats = () => {
// Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering
const totalPlayerCount = this.getTotalPlayerCount()
if (totalPlayerCount !== this._lastTotalPlayerCount) {
this._lastTotalPlayerCount = totalPlayerCount
document.getElementById('stat_totalPlayers').innerText = formatNumber(totalPlayerCount)
}
// Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering
const serverRegistrationCount = this.serverRegistry.getServerRegistrations().length
if (serverRegistrationCount !== this._lastServerRegistrationCount) {
this._lastServerRegistrationCount = serverRegistrationCount
document.getElementById('stat_networks').innerText = serverRegistrationCount
}
}
}

69
assets/js/favorites.js Normal file

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

@ -1,130 +1,370 @@
// Used by the individual server entries import { formatNumber, formatTimestamp, isMobileBrowser } from './util'
var smallChartOptions = {
series: {
shadowSize: 0
},
xaxis: {
font: {
color: "#E3E3E3"
},
show: false
},
yaxis: {
minTickSize: 75,
tickDecimals: 0,
show: true,
tickLength: 10,
tickFormatter: function(value) {
return formatNumber(value);
},
font: {
color: "#E3E3E3"
},
labelWidth: -10
},
grid: {
hoverable: true,
color: "#696969"
},
colors: [
"#E9E581"
]
};
// Used by the one chart to rule them all import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites'
var bigChartOptions = {
export const HISTORY_GRAPH_OPTIONS = {
series: { series: {
shadowSize: 0 shadowSize: 0
}, },
xaxis: { xaxis: {
font: { font: {
color: "#E3E3E3" color: '#E3E3E3'
}, },
show: false show: false
}, },
yaxis: { yaxis: {
show: true, show: true,
tickSize: 2000, tickSize: 5000,
tickLength: 10, tickLength: 10,
tickFormatter: function(value) { tickFormatter: formatNumber,
return formatNumber(value);
},
font: { font: {
color: "#E3E3E3" color: '#E3E3E3'
}, },
labelWidth: -5, labelWidth: -5,
min: 0 min: 0
}, },
grid: { grid: {
hoverable: true, hoverable: true,
color: "#696969" color: '#696969'
}, },
legend: { legend: {
show: false show: false
} }
};
function toggleControlsDrawer() {
var div = $('#big-graph-controls-drawer');
div.css('display', div.css('display') !== 'none' ? 'none' : 'block');
} }
function saveGraphControls(displayedServers) { const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers'
if (typeof(localStorage)) { const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites'
var json = JSON.stringify(displayedServers);
localStorage.setItem('displayedServers', json); export class GraphDisplayManager {
} // Only emit graph data request if not on mobile due to graph data size
} isVisible = !isMobileBrowser()
function loadGraphControls() { constructor (app) {
if (typeof(localStorage)) { this._app = app
var item = localStorage.getItem('displayedServers'); this._graphData = []
this._hasLoadedSettings = false
if (item) { this._initEventListenersOnce = false
return JSON.parse(item); this._showOnlyFavorites = false
}
}
}
function resetGraphControls() {
if (typeof(localStorage)) {
localStorage.removeItem('displayedServers');
}
}
// Called by flot.js when they hover over a data point.
function handlePlotHover(event, pos, item) {
if (item) {
var text = getTimestamp(item.datapoint[0] / 1000) + '\
<br />\
' + formatNumber(item.datapoint[1]) + ' Players';
if (item.series && item.series.label) {
text = item.series.label + '<br />' + text;
} }
renderTooltip(item.pageX + 5, item.pageY + 5, text); addGraphPoint (serverId, timestamp, playerCount) {
if (!this._hasLoadedSettings) {
// _hasLoadedSettings is controlled by #setGraphData
// It will only be true once the context has been loaded and initial payload received
// #addGraphPoint should not be called prior to that since it means the data is racing
// and the application has received updates prior to the initial state
return
}
// Trim any outdated entries by filtering the array into a new array
const startTimestamp = new Date().getTime()
const newGraphData = this._graphData[serverId].filter(point => startTimestamp - point[0] <= this._app.publicConfig.graphDuration)
// Push the new data from the method call request
newGraphData.push([timestamp, playerCount])
this._graphData[serverId] = newGraphData
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY)
if (showOnlyFavorites) {
this._showOnlyFavorites = true
}
// If only favorites mode is active, use the stored favorite servers data instead
let serverNames
if (this._showOnlyFavorites) {
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
} else { } else {
hideTooltip(); serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY)
} }
}
// Converts the backend data into the schema used by flot.js if (serverNames) {
function convertGraphData(rawData) { serverNames = JSON.parse(serverNames)
var data = [];
var keys = Object.keys(rawData); // Iterate over all active serverRegistrations
// This merges saved state with current state to prevent desyncs
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
// isVisible will be true if showOnlyFavorites && contained in FAVORITE_SERVERS_STORAGE_KEY
// OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY
// Checks between FAVORITE/HIDDEN keys are mutually exclusive
if (this._showOnlyFavorites) {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0
} else {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0
}
}
}
}
}
for (var i = 0; i < keys.length; i++) { updateLocalStorage () {
data.push({ if (typeof localStorage !== 'undefined') {
data: rawData[keys[i]], // Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => !serverRegistration.isVisible)
.map(serverRegistration => serverRegistration.data.name)
// Only store if the array contains data, otherwise clear the item
// If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead
if (serverNames.length > 0 && !this._showOnlyFavorites) {
localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
} else {
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY)
}
// Only store SHOW_FAVORITES_STORAGE_KEY if true
if (this._showOnlyFavorites) {
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true)
} else {
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY)
}
}
}
// Converts the backend data into the schema used by flot.js
getVisibleGraphData () {
return Object.keys(this._graphData)
.map(Number)
.map(serverId => this._app.serverRegistry.getServerRegistration(serverId))
.filter(serverRegistration => serverRegistration !== undefined && serverRegistration.isVisible)
.map(serverRegistration => {
return {
data: this._graphData[serverRegistration.serverId],
yaxis: 1, yaxis: 1,
label: keys[i], label: serverRegistration.data.name,
color: getServerColor(keys[i]) color: serverRegistration.data.color
}); }
})
} }
return data; buildPlotInstance (graphData) {
// Lazy load settings from localStorage, if any and if enabled
if (!this._hasLoadedSettings) {
this._hasLoadedSettings = true
this.loadLocalStorage()
}
// Remap the incoming data from being string (serverName) keyed into serverId keys
for (const serverName of Object.keys(graphData)) {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName)
this._graphData[serverRegistration.serverId] = graphData[serverName]
}
// Explicitly define a height so flot.js can rescale the Y axis
document.getElementById('big-graph').style.height = '400px'
this._plotInstance = $.plot('#big-graph', this.getVisibleGraphData(), HISTORY_GRAPH_OPTIONS)
// Show the settings-toggle element
document.getElementById('settings-toggle').style.display = 'inline-block'
}
// requestRedraw allows usages to request a redraw that may be performed, or cancelled, sometime later
// This allows multiple rapid, but individual updates, to clump into a single redraw instead
requestRedraw () {
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
}
// Schedule new delayed redraw call
// This can be cancelled by #requestRedraw, #redraw and #reset
this._redrawRequestTimeout = setTimeout(this.redraw, 1000)
}
redraw = () => {
// Use drawing as a hint to update settings
// This may cause unnessecary localStorage updates, but its a rare and harmless outcome
this.updateLocalStorage()
// Fire calls to the provided graph instance
// This allows flot.js to manage redrawing and creates a helper method to reduce code duplication
this._plotInstance.setData(this.getVisibleGraphData())
this._plotInstance.setupGrid()
this._plotInstance.draw()
// undefine value so #clearTimeout is not called
// This is safe even if #redraw is manually called since it removes the pending work
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
}
this._redrawRequestTimeout = undefined
}
requestResize () {
// Only resize when _plotInstance is defined
// Set a timeout to resize after resize events have not been fired for some duration of time
// This prevents burning CPU time for multiple, rapid resize events
if (this._plotInstance) {
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
}
// Schedule new delayed resize call
// This can be cancelled by #requestResize, #resize and #reset
this._resizeRequestTimeout = setTimeout(this.resize, 200)
}
}
resize = () => {
if (this._plotInstance) {
this._plotInstance.resize()
this._plotInstance.setupGrid()
this._plotInstance.draw()
}
// undefine value so #clearTimeout is not called
// This is safe even if #resize is manually called since it removes the pending work
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
}
this._resizeRequestTimeout = undefined
}
// Called by flot.js when they hover over a data point.
handlePlotHover = (event, pos, item) => {
if (!item) {
this._app.tooltip.hide()
} else {
let text = formatNumber(item.datapoint[1]) + ' Players<br>' + formatTimestamp(item.datapoint[0])
// Prefix text with the series label when possible
if (item.series && item.series.label) {
text = '<strong>' + item.series.label + '</strong><br>' + text
}
this._app.tooltip.set(item.pageX, item.pageY, 10, 10, text)
}
}
initEventListeners () {
if (!this._initEventListenersOnce) {
this._initEventListenersOnce = true
// These listeners should only be init once since they attach to persistent elements
document.getElementById('settings-toggle').addEventListener('click', this.handleSettingsToggle, false)
document.querySelectorAll('.graph-controls-show').forEach((element) => {
element.addEventListener('click', this.handleShowButtonClick, false)
})
}
$('#big-graph').bind('plothover', this.handlePlotHover)
// These listeners should be bound each #initEventListeners call since they are for newly created elements
document.querySelectorAll('.graph-control').forEach((element) => {
element.addEventListener('click', this.handleServerButtonClick, false)
})
}
handleServerButtonClick = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
if (serverRegistration.isVisible !== event.target.checked) {
serverRegistration.isVisible = event.target.checked
// Any manual changes automatically disables "Only Favorites" mode
// Otherwise the auto management might overwrite their manual changes
this._showOnlyFavorites = false
this.redraw()
}
}
handleShowButtonClick = (event) => {
const showType = event.target.getAttribute('minetrack-show-type')
// If set to "Only Favorites", set internal state so that
// visible graphData is automatically updating when a ServerRegistration's #isVisible changes
// This is also saved and loaded by #loadLocalStorage & #updateLocalStorage
this._showOnlyFavorites = showType === 'favorites'
let redraw = false
this._app.serverRegistry.getServerRegistrations().forEach(function (serverRegistration) {
let isVisible
if (showType === 'all') {
isVisible = true
} else if (showType === 'none') {
isVisible = false
} else if (showType === 'favorites') {
isVisible = serverRegistration.isFavorite
}
if (serverRegistration.isVisible !== isVisible) {
serverRegistration.isVisible = isVisible
redraw = true
}
})
if (redraw) {
this.redraw()
this.updateCheckboxes()
}
}
handleSettingsToggle = () => {
const element = document.getElementById('big-graph-controls-drawer')
if (element.style.display !== 'block') {
element.style.display = 'block'
} else {
element.style.display = 'none'
}
}
handleServerIsFavoriteUpdate = (serverRegistration) => {
// When in "Only Favorites" mode, visibility is dependent on favorite status
// Redraw and update elements as needed
if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) {
serverRegistration.isVisible = serverRegistration.isFavorite
this.redraw()
this.updateCheckboxes()
}
}
updateCheckboxes () {
document.querySelectorAll('.graph-control').forEach((checkbox) => {
const serverId = parseInt(checkbox.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
checkbox.checked = serverRegistration.isVisible
})
}
reset () {
this._graphData = []
this._plotInstance = undefined
this._hasLoadedSettings = false
// Fire #clearTimeout if the timeout is currently defined
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
this._resizeRequestTimeout = undefined
}
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
this._redrawRequestTimeout = undefined
}
// Reset modified DOM structures
document.getElementById('big-graph-checkboxes').innerHTML = ''
document.getElementById('big-graph-controls').style.display = 'none'
document.getElementById('settings-toggle').style.display = 'none'
const graphElement = document.getElementById('big-graph')
graphElement.innerHTML = ''
graphElement.removeAttribute('style')
}
} }

159
assets/js/main.js Normal file

@ -0,0 +1,159 @@
import { App } from './app'
import io from 'socket.io-client'
const app = new App()
document.addEventListener('DOMContentLoaded', function () {
const socket = io.connect({
reconnect: true,
reconnectDelay: 1000,
reconnectionAttempts: 10
})
// The backend will automatically push data once connected
socket.on('connect', function () {
app.caption.set('Loading...')
})
socket.on('disconnect', function () {
app.handleDisconnect()
// Reset modified DOM structures
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
})
socket.on('historyGraph', function (data) {
// Consider the graph visible since a payload has been received
// This is used for the manual graph load request behavior
app.graphDisplayManager.isVisible = true
app.graphDisplayManager.buildPlotInstance(data)
// Build checkbox elements for graph controls
let lastRowCounter = 0
let controlsHTML = ''
Object.keys(data).sort().forEach(function (serverName) {
const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
controlsHTML += '<td>' +
'<input type="checkbox" class="graph-control" minetrack-server-id="' + serverRegistration.serverId + '" ' + (serverRegistration.isVisible ? 'checked' : '') + '>' +
' ' + serverName +
'</input></td>'
// Occasionally break table rows using a magic number
if (++lastRowCounter % 6 === 0) {
controlsHTML += '</tr><tr>'
}
})
// Apply generated HTML and show controls
document.getElementById('big-graph-checkboxes').innerHTML = '<table><tr>' +
controlsHTML +
'</tr></table>'
document.getElementById('big-graph-controls').style.display = 'block'
// Bind click event for updating graph data
app.graphDisplayManager.initEventListeners()
})
socket.on('updateHistoryGraph', function (data) {
// Skip any incoming updates if the graph is disabled
// The backend shouldn't send these anyways
if (!app.graphDisplayManager.isVisible) {
return
}
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
if (serverRegistration) {
app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.players)
// Only redraw the graph if not mutating hidden data
if (serverRegistration.isVisible) {
app.graphDisplayManager.requestRedraw()
}
}
})
socket.on('add', function (data) {
data.forEach(app.addServer)
})
socket.on('update', function (data) {
// The backend may send "update" events prior to receiving all "add" events
// A server has only been added once it's ServerRegistration is defined
// Checking undefined protects from this race condition
const serverRegistration = app.serverRegistry.getServerRegistration(data.info.name)
if (serverRegistration) {
serverRegistration.updateServerStatus(data, false, app.publicConfig.minecraftVersions)
}
})
socket.on('updateMojangServices', function (data) {
Object.values(data).forEach(app.mojangUpdater.updateServiceStatus)
})
socket.on('setPublicConfig', function (data) {
app.setPublicConfig(data)
// Display the main page component
// Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn
// Otherwise flot.js will cause visual alignment bugs
app.setPageReady(true)
// Allow the graphDisplayManager to control whether or not the historical graph is loaded
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
if (data.isGraphVisible) {
if (app.graphDisplayManager.isVisible) {
socket.emit('requestHistoryGraph')
} else {
document.getElementById('big-graph-mobile-load-request').style.display = 'block'
}
}
})
// Fired once the backend has sent all requested data
socket.on('syncComplete', function () {
app.handleSyncComplete()
})
socket.on('updatePeak', function (data) {
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
if (serverRegistration) {
serverRegistration.updateServerPeak(data.timestamp, data.players)
}
})
socket.on('peaks', function (data) {
Object.keys(data).forEach(function (serverName) {
const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
if (serverRegistration) {
const graphData = data[serverName]
// [0] and [1] indexes correspond to flot.js' graphing data structure
serverRegistration.updateServerPeak(graphData[0], graphData[1])
}
})
})
window.addEventListener('resize', function () {
app.percentageBar.redraw()
// Delegate to GraphDisplayManager which can check if the resize is necessary
app.graphDisplayManager.requestResize()
}, false)
document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () {
// Send a graph data request to the backend
socket.emit('requestHistoryGraph')
// Hide the activation link to avoid multiple requests
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
}, false)
}, false)

20
assets/js/mojang.js Normal file

@ -0,0 +1,20 @@
const MOJANG_STATUS_BASE_CLASS = 'header-button header-button-group'
export class MojangUpdater {
updateServiceStatus (status) {
// HACK: ensure mojang-status is added for alignment, replace existing class to swap status color
document.getElementById('mojang-status_' + status.name).setAttribute('class', MOJANG_STATUS_BASE_CLASS + ' mojang-status-' + status.title.toLowerCase())
document.getElementById('mojang-status-text_' + status.name).innerText = status.title
}
reset () {
// Strip any mojang-status-* color classes from all mojang-status classes
document.querySelectorAll('.mojang-status').forEach(function (element) {
element.setAttribute('class', MOJANG_STATUS_BASE_CLASS)
})
document.querySelectorAll('.mojang-status-text').forEach(function (element) {
element.innerText = '...'
})
}
}

72
assets/js/percbar.js Normal file

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

304
assets/js/servers.js Normal file

@ -0,0 +1,304 @@
import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions, isArrayEqual, isObjectEqual } from './util'
import MISSING_FAVICON from '../images/missing_favicon.svg'
export const SERVER_GRAPH_OPTIONS = {
series: {
shadowSize: 0
},
xaxis: {
font: {
color: '#E3E3E3'
},
show: false
},
yaxis: {
minTickSize: 100,
tickDecimals: 0,
show: true,
tickLength: 10,
tickFormatter: formatNumber,
font: {
color: '#E3E3E3'
},
labelWidth: -10
},
grid: {
hoverable: true,
color: '#696969'
},
colors: [
'#E9E581'
]
}
export class ServerRegistry {
constructor (app) {
this._app = app
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
}
assignServers (servers) {
for (let i = 0; i < servers.length; i++) {
const data = servers[i]
this._serverIdsByName[data.name] = i
this._serverDataById[i] = data
}
}
createServerRegistration (serverName) {
const serverId = this._serverIdsByName[serverName]
const serverData = this._serverDataById[serverId]
const serverRegistration = new ServerRegistration(this._app, serverId, serverData)
this._registeredServers[serverId] = serverRegistration
return serverRegistration
}
getServerRegistration (serverKey) {
if (typeof serverKey === 'string') {
const serverId = this._serverIdsByName[serverKey]
return this._registeredServers[serverId]
} else if (typeof serverKey === 'number') {
return this._registeredServers[serverKey]
}
}
getServerRankBy (serverRegistration, x, sort) {
const records = Object.values(this._registeredServers)
.map(x)
.filter(val => val !== undefined)
// Invalidate any results that do not account for all serverRegistrations
if (records.length === this._registeredServers.length) {
records.sort(sort)
// Pull matching data from target serverRegistration
// Assume indexOf cannot be -1 or val undefined since they have been pre-tested in the map call above
const val = x(serverRegistration)
const indexOf = records.indexOf(val)
return indexOf + 1
}
}
getServerRegistrations = () => Object.values(this._registeredServers)
reset () {
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
// Reset modified DOM structures
document.getElementById('server-list').innerHTML = ''
}
}
const SERVER_GRAPH_DATA_MAX_LENGTH = 72
export class ServerRegistration {
playerCount = 0
isVisible = true
isFavorite = false
rankIndex
lastRecordData
lastVersions = []
lastPeakData
constructor (app, serverId, data) {
this._app = app
this.serverId = serverId
this.data = data
this._graphData = []
this._failedSequentialPings = 0
}
addGraphPoints (points) {
// Test if the first point contains error.placeholder === true
// This is sent by the backend when the server hasn't been pinged yet
// These points will be disregarded to prevent the graph starting at 0 player count
points = points.filter(point => !point.error || !point.error.placeholder)
// The backend should never return more data elements than the max
// but trim the data result regardless for safety and performance purposes
if (points.length > SERVER_GRAPH_DATA_MAX_LENGTH) {
points.slice(points.length - SERVER_GRAPH_DATA_MAX_LENGTH, points.length)
}
this._graphData = points.map(point => point.result ? [point.timestamp, point.result.players.online] : [point.timestamp, 0])
}
buildPlotInstance () {
this._plotInstance = $.plot('#chart_' + this.serverId, [this._graphData], SERVER_GRAPH_OPTIONS)
}
handlePing (payload, pushToGraph) {
if (payload.result) {
this.playerCount = payload.result.players.online
if (pushToGraph) {
// Only update graph for successful pings
// This intentionally pauses the server graph when pings begin to fail
this._graphData.push([payload.info.timestamp, this.playerCount])
// Trim graphData to within the max length by shifting out the leading elements
if (this._graphData.length > SERVER_GRAPH_DATA_MAX_LENGTH) {
this._graphData.shift()
}
this.redraw()
}
// Reset failed ping counter to ensure the next connection error
// doesn't instantly retrigger a layout change
this._failedSequentialPings = 0
} else {
// Attempt to retain a copy of the cached playerCount for up to N failed pings
// This prevents minor connection issues from constantly reshuffling the layout
if (++this._failedSequentialPings > 5) {
this.playerCount = 0
}
}
}
redraw () {
// Redraw the plot instance
this._plotInstance.setData([this._graphData])
this._plotInstance.setupGrid()
this._plotInstance.draw()
}
updateServerRankIndex (rankIndex) {
this.rankIndex = rankIndex
document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1)
}
updateServerPeak (time, playerCount) {
const peakLabelElement = document.getElementById('peak_' + this.serverId)
// Always set label once any peak data has been received
peakLabelElement.style.display = 'block'
const peakValueElement = document.getElementById('peak-value_' + this.serverId)
peakValueElement.innerText = formatNumber(playerCount)
peakLabelElement.title = 'At ' + formatTimestamp(time)
this.lastPeakData = {
timestamp: time,
playerCount: playerCount
}
}
updateServerStatus (ping, isInitialUpdate, minecraftVersions) {
// Only pushToGraph when initialUpdate === false
// Otherwise the ping value is pushed into the graphData when already present
this.handlePing(ping, !isInitialUpdate)
// Compare against a cached value to avoid empty updates
// Allow undefined ping.versions inside the if statement for text reset handling
if (ping.versions && !isArrayEqual(ping.versions, this.lastVersions)) {
this.lastVersions = ping.versions
const versionsElement = document.getElementById('version_' + this.serverId)
versionsElement.style.display = 'block'
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[ping.info.type]) || ''
}
// Compare against a cached value to avoid empty updates
if (ping.recordData !== undefined && !isObjectEqual(ping.recordData, this.lastRecordData, ['playerCount', 'timestamp'])) {
this.lastRecordData = ping.recordData
// Always set label once any record data has been received
const recordLabelElement = document.getElementById('record_' + this.serverId)
recordLabelElement.style.display = 'block'
const recordValueElement = document.getElementById('record-value_' + this.serverId)
const recordData = ping.recordData
// Safely handle legacy recordData that may not include the timestamp payload
if (recordData.timestamp !== -1) {
recordValueElement.innerHTML = formatNumber(recordData.playerCount) + ' (' + formatDate(recordData.timestamp) + ')'
recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestamp(recordData.timestamp)
} else {
recordValueElement.innerText = formatNumber(recordData.playerCount)
}
}
const playerCountLabelElement = document.getElementById('player-count_' + this.serverId)
const errorElement = document.getElementById('error_' + this.serverId)
if (ping.error) {
// Hide any visible player-count and show the error element
playerCountLabelElement.style.display = 'none'
errorElement.style.display = 'block'
// Attempt to find an error cause from documented options
errorElement.innerText = ping.error.description || ping.error.errno || 'Unknown error'
} else if (ping.result) {
// Ensure the player-count element is visible and hide the error element
playerCountLabelElement.style.display = 'block'
errorElement.style.display = 'none'
document.getElementById('player-count-value_' + this.serverId).innerText = formatNumber(ping.result.players.online)
// An updated favicon has been sent, update the src
// Ignore calls from 'add' events since they will have explicitly manually handled the favicon update
if (!isInitialUpdate && ping.favicon) {
document.getElementById('favicon_' + this.serverId).setAttribute('src', ping.favicon)
}
}
}
initServerStatus (latestPing) {
const peakHourDuration = Math.floor(this._app.publicConfig.graphDuration / (60 * 60 * 1000)) + 'h Peak: '
const serverElement = document.createElement('div')
serverElement.id = 'container_' + this.serverId
serverElement.innerHTML = '<div class="column column-favicon">' +
'<img class="server-favicon" src="' + (latestPing.favicon || MISSING_FAVICON) + '" id="favicon_' + this.serverId + '" title="' + this.data.name + '\n' + formatMinecraftServerAddress(this.data.ip, this.data.port) + '">' +
'<span class="server-rank" id="ranking_' + this.serverId + '"></span>' +
'</div>' +
'<div class="column column-status">' +
'<h3 class="server-name"><span class="' + this._app.favoritesManager.getIconClass(this.isFavorite) + '" id="favorite-toggle_' + this.serverId + '"></span> ' + this.data.name + '</h3>' +
'<span class="server-error" id="error_' + this.serverId + '"></span>' +
'<span class="server-label" id="player-count_' + this.serverId + '">Players: <span class="server-value" id="player-count-value_' + this.serverId + '"></span></span>' +
'<span class="server-label" id="peak_' + this.serverId + '">' + peakHourDuration + '<span class="server-value" id="peak-value_' + this.serverId + '">-</span></span>' +
'<span class="server-label" id="record_' + this.serverId + '">Record: <span class="server-value" id="record-value_' + this.serverId + '">-</span></span>' +
'<span class="server-label" id="version_' + this.serverId + '"></span>' +
'</div>' +
'<div class="column column-graph" id="chart_' + this.serverId + '"></div>'
serverElement.setAttribute('class', 'server')
document.getElementById('server-list').appendChild(serverElement)
}
updateHighlightedValue (selectedCategory) {
['player-count', 'peak', 'record'].forEach((category) => {
const labelElement = document.getElementById(category + '_' + this.serverId)
const valueElement = document.getElementById(category + '-value_' + this.serverId)
if (selectedCategory && category === selectedCategory) {
labelElement.setAttribute('class', 'server-highlighted-label')
valueElement.setAttribute('class', 'server-highlighted-value')
} else {
labelElement.setAttribute('class', 'server-label')
valueElement.setAttribute('class', 'server-value')
}
})
}
initEventListeners () {
$('#chart_' + this.serverId).bind('plothover', this._app.graphDisplayManager.handlePlotHover)
document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => {
this._app.favoritesManager.handleFavoriteButtonClick(this)
}, false)
}
}

@ -1,538 +0,0 @@
var graphs = [];
var lastPlayerEntries = [];
var historyPlot;
var displayedGraphData;
var hiddenGraphData = [];
var isConnected = false;
var mojangServicesUpdater;
var sortServersTask;
var currentServerHover;
var faviconSize = 64;
function updateServerStatus(lastEntry) {
var info = lastEntry.info;
var div = $('#status_' + safeName(info.name));
var versionDiv = $('#version_' + safeName(info.name));
if (lastEntry.versions) {
var versions = '';
for (var i = 0; i < lastEntry.versions.length; i++) {
if (!lastEntry.versions[i]) continue;
versions += '<span class="version">' + publicConfig.minecraftVersions[lastEntry.info.type][lastEntry.versions[i]] + '</span>&nbsp;';
}
versionDiv.html(versions);
} else {
versionDiv.html('');
}
if (lastEntry.result) {
var result = lastEntry.result;
var newStatus = 'Players: <span style="font-weight: 500;">' + formatNumber(result.players.online) + '</span>';
var listing = graphs[lastEntry.info.name].listing;
if (listing.length > 0) {
newStatus += '<span class="color-gray"> (';
var playerDifference = listing[listing.length - 1][1] - listing[0][1];
if (playerDifference >= 0) {
newStatus += '+';
}
newStatus += playerDifference + ')</span>';
}
lastPlayerEntries[info.name] = result.players.online;
div.html(newStatus);
} else {
var newStatus = '<span class="color-red">';
if (findErrorMessage(lastEntry.error)) {
newStatus += findErrorMessage(lastEntry.error);
} else {
newStatus += 'Failed to ping!';
}
div.html(newStatus + '</span>');
}
var keys = Object.keys(lastPlayerEntries);
var totalPlayers = 0;
for (var i = 0; i < keys.length; i++) {
totalPlayers += lastPlayerEntries[keys[i]];
}
$("#stat_totalPlayers").text(formatNumber(totalPlayers));
$("#stat_networks").text(formatNumber(keys.length));
if (lastEntry.record) {
$('#record_' + safeName(info.name)).html('Record: ' + formatNumber(lastEntry.record));
}
updatePercentageBar();
}
function sortServers() {
var serverNames = [];
var keys = Object.keys(lastPlayerEntries);
for (var i = 0; i < keys.length; i++) {
serverNames.push(keys[i]);
}
serverNames.sort(function(a, b) {
return (lastPlayerEntries[b] || 0) - (lastPlayerEntries[a] || 0);
});
for (var i = 0; i < serverNames.length; i++) {
$('#container_' + safeName(serverNames[i])).appendTo('#server-container-list');
$('#ranking_' + safeName(serverNames[i])).text('#' + (i + 1));
}
}
function updatePercentageBar() {
var keys = Object.keys(lastPlayerEntries);
keys.sort(function(a, b) {
return lastPlayerEntries[a] - lastPlayerEntries[b];
});
var totalPlayers = getCurrentTotalPlayers();
var parent = $('#perc-bar');
var leftPadding = 0;
for (var i = 0; i < keys.length; i++) {
(function(pos, server, length) {
var safeNameCopy = safeName(server);
var playerCount = lastPlayerEntries[server];
var div = $('#perc_bar_part_' + safeNameCopy);
// Setup the base
if (!div.length) {
$('<div/>', {
id: 'perc_bar_part_' + safeNameCopy,
class: 'perc-bar-part',
html: '',
style: 'background: ' + getServerColor(server) + ';'
}).appendTo(parent);
div = $('#perc_bar_part_' + safeNameCopy);
div.mouseover(function(e) {
currentServerHover = server;
});
div.mouseout(function(e) {
hideTooltip();
currentServerHover = undefined;
});
}
// Update our position/width
var width = (playerCount / totalPlayers) * parent.width();
div.css({
width: width + 'px',
left: leftPadding + 'px'
});
leftPadding += width;
})(i, keys[i], keys.length);
}
}
function getCurrentTotalPlayers() {
var totalPlayers = 0;
var keys = Object.keys(lastPlayerEntries);
for (var i = 0; i < keys.length; i++) totalPlayers += lastPlayerEntries[keys[i]]
return totalPlayers;
}
function setAllGraphVisibility(visible) {
if (visible) {
var keys = Object.keys(hiddenGraphData);
for (var i = 0; i < keys.length; i++) {
displayedGraphData[keys[i]] = hiddenGraphData[keys[i]];
}
hiddenGraphData = [];
} else {
var keys = Object.keys(displayedGraphData);
for (var i = 0; i < keys.length; i++) {
hiddenGraphData[keys[i]] = displayedGraphData[keys[i]];
}
displayedGraphData = [];
}
$('.graph-control').each(function(index, item) {
item.checked = visible;
});
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
// Update our localStorage
if (visible) {
resetGraphControls();
} else {
saveGraphControls(Object.keys(displayedGraphData));
}
}
function validateBootTime(bootTime, socket) {
$('#tagline-text').text('Validating...');
console.log('Remote bootTime is ' + bootTime + ', local is ' + publicConfig.bootTime);
if (bootTime === publicConfig.bootTime) {
$('#tagline-text').text('Loading...');
socket.emit('requestListing');
if (!isMobileBrowser()) socket.emit('requestHistoryGraph');
isConnected = true;
// Start any special updating tasks.
mojangServicesUpdater = setInterval(updateMojangServices, 1000);
sortServersTask = setInterval(sortServers, 10000);
} else {
$('#tagline-text').text('Updating...');
$.getScript('/publicConfig.json', function(data, textStatus, xhr) {
if (xhr.status === 200) {
validateBootTime(publicConfig.bootTime, socket);
} else {
showCaption('Failed to update! Refresh?');
}
});
}
}
function printPort(port) {
if(port == undefined || port == 25565) {
return "";
} else {
return ":" + port;
}
}
function updateServerPeak(name, time, playerCount) {
var safeNameCopy = safeName(name);
// hack: strip the AM/PM suffix
// Javascript doesn't have a nice way to format Dates with AM/PM, so we'll append it manually
var timestamp = getTimestamp(time / 1000).split(':');
var end = timestamp.pop().split(' ')[1];
timestamp = timestamp.join(':');
// end may be undefined for other timezones/24 hour times
if (end) {
timestamp += ' ' + end;
}
var timeLabel = msToTime(publicConfig.graphDuration);
$('#peak_' + safeNameCopy).html(timeLabel + ' Peak: ' + formatNumber(playerCount) + ' @ ' + timestamp);
}
$(document).ready(function() {
var socket = io.connect({
reconnect: true,
reconnectDelay: 1000,
reconnectionAttempts: 10
});
socket.on('bootTime', function(bootTime) {
validateBootTime(bootTime, socket);
});
socket.on('disconnect', function() {
if (mojangServicesUpdater) clearInterval(mojangServicesUpdater);
if (sortServersTask) clearInterval(sortServersTask);
lastMojangServiceUpdate = undefined;
showCaption('Disconnected! Refresh?');
lastPlayerEntries = {};
graphs = {};
$('#server-container-list').html('');
$('#big-graph').html('');
$('#big-graph-checkboxes').html('');
$('#big-graph-controls').css('display', 'none');
$('#perc-bar').html('');
$('.mojang-status').css('background', 'transparent');
$('.mojang-status-text').text('...');
$("#stat_totalPlayers").text(0);
$("#stat_networks").text(0);
isConnected = false;
});
socket.on('historyGraph', function(rawData) {
var shownServers = loadGraphControls();
if (shownServers) {
var keys = Object.keys(rawData);
hiddenGraphData = [];
displayedGraphData = [];
for (var i = 0; i < keys.length; i++) {
var name = keys[i];
if (shownServers.indexOf(name) !== -1) {
displayedGraphData[name] = rawData[name];
} else {
hiddenGraphData[name] = rawData[name];
}
}
} else {
displayedGraphData = rawData;
}
$('#big-graph').css('height', '400px');
historyPlot = $.plot('#big-graph', convertGraphData(displayedGraphData), bigChartOptions);
$('#big-graph').bind('plothover', handlePlotHover);
var keys = Object.keys(rawData);
var sinceBreak = 0;
var html = '<table><tr>';
keys.sort();
for (var i = 0; i < keys.length; i++) {
var checkedString = '';
if (displayedGraphData[keys[i]]) {
checkedString = 'checked=checked';
}
html += '<td><input type="checkbox" class="graph-control" id="graph-controls" data-target-network="' + keys[i] + '" ' + checkedString + '> ' + keys[i] + '</input></td>';
if (sinceBreak >= 7) {
sinceBreak = 0;
html += '</tr><tr>';
} else {
sinceBreak++;
}
}
$('#big-graph-checkboxes').append(html + '</tr></table>');
$('#big-graph-controls').css('display', 'block');
});
socket.on('updateHistoryGraph', function(rawData) {
// Prevent race conditions.
if (!displayedGraphData || !hiddenGraphData) {
return;
}
// If it's not in our display group, use the hidden group instead.
var targetGraphData = displayedGraphData[rawData.name] ? displayedGraphData : hiddenGraphData;
trimOldPings(targetGraphData, publicConfig.graphDuration);
targetGraphData[rawData.name].push([rawData.timestamp, rawData.players]);
// Redraw if we need to.
if (displayedGraphData[rawData.name]) {
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
}
});
socket.on('add', function(servers) {
for (var i = 0; i < servers.length; i++) {
var history = servers[i];
var listing = [];
for (var x = 0; x < history.length; x++) {
var point = history[x];
if (point.result) {
listing.push([point.timestamp, point.result.players.online]);
} else if (point.error) {
listing.push([point.timestamp, 0]);
}
}
var lastEntry = history[history.length - 1];
var info = lastEntry.info;
if (lastEntry.error) {
lastPlayerEntries[info.name] = 0;
} else if (lastEntry.result) {
lastPlayerEntries[info.name] = lastEntry.result.players.online;
}
var typeString = publicConfig.serverTypesVisible ? '<span class="type">' + info.type + '</span>' : '';
var safeNameCopy = safeName(info.name);
$('<div/>', {
id: 'container_' + safeNameCopy,
class: 'server',
'server-id': safeNameCopy,
html: '<div id="server-' + safeNameCopy + '" class="column" style="width: 80px;">\
<img id="favicon_' + safeNameCopy + '" title="' + info.name + '\n' + info.ip + printPort(info.port) + '" height="' + faviconSize + '" width="' + faviconSize + '">\
<br />\
<p class="text-center-align rank" id="ranking_' + safeNameCopy + '"></p>\
</div>\
<div class="column" style="width: 282px;">\
<h3>' + info.name + '&nbsp;' + typeString + '</h3>\
<span id="status_' + safeNameCopy + '">Waiting</span>\
<div id="version_' + safeNameCopy + '" class="color-dark-gray server-meta versions"><span class="version"></span></div>\
<span id="peak_' + safeNameCopy + '" class="color-dark-gray server-meta"></span>\
<br><span id="record_' + safeNameCopy + '" class="color-dark-gray server-meta"></span>\
</div>\
<div class="column" style="float: right;">\
<div class="chart" id="chart_' + safeNameCopy + '"></div>\
</div>'
}).appendTo("#server-container-list");
var favicon = MISSING_FAVICON_BASE64;
if (lastEntry.result && lastEntry.result.favicon) {
favicon = lastEntry.result.favicon;
}
$('#favicon_' + safeName(info.name)).attr('src', favicon);
graphs[lastEntry.info.name] = {
listing: listing,
plot: $.plot('#chart_' + safeNameCopy, [listing], smallChartOptions)
};
updateServerStatus(lastEntry);
$('#chart_' + safeNameCopy).bind('plothover', handlePlotHover);
}
sortServers();
updatePercentageBar();
});
socket.on('update', function(update) {
// Prevent weird race conditions.
if (!graphs[update.info.name]) {
return;
}
// We have a new favicon, update the old one.
if (update.result && update.result.favicon) {
$('#favicon_' + safeName(update.info.name)).attr('src', update.result.favicon);
}
var graph = graphs[update.info.name];
updateServerStatus(update);
if (update.result) {
graph.listing.push([update.info.timestamp, update.result ? update.result.players.online : 0]);
if (graph.listing.length > 72) {
graph.listing.shift();
}
graph.plot.setData([graph.listing]);
graph.plot.setupGrid();
graph.plot.draw();
}
});
socket.on('updateMojangServices', function(data) {
if (isConnected) {
updateMojangServices(data);
}
});
socket.on('syncComplete', function() {
hideCaption();
});
socket.on('updatePeak', function(data) {
updateServerPeak(data.name, data.timestamp, data.players);
});
socket.on('peaks', function(data) {
var keys = Object.keys(data);
for (var i = 0; i < keys.length; i++) {
var val = data[keys[i]];
updateServerPeak(keys[i], val[0], val[1]);
}
});
$(document).on('click', '.graph-control', function(e) {
var serverIp = $(this).attr('data-target-network');
// Restore it, or delete it - either works.
if (!this.checked) {
hiddenGraphData[serverIp] = displayedGraphData[serverIp];
delete displayedGraphData[serverIp];
} else {
displayedGraphData[serverIp] = hiddenGraphData[serverIp];
delete hiddenGraphData[serverIp];
}
// Redraw the graph
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
// Update our localStorage
if (Object.keys(hiddenGraphData).length === 0) {
resetGraphControls();
} else {
saveGraphControls(Object.keys(displayedGraphData));
}
});
$(document).on('mousemove', function(e) {
if (currentServerHover) {
var totalPlayers = getCurrentTotalPlayers();
var playerCount = lastPlayerEntries[currentServerHover];
renderTooltip(e.pageX + 10, e.pageY + 10, '<strong>' + currentServerHover + '</strong>: ' + roundToPoint(playerCount / totalPlayers * 100, 10) + '% of ' + formatNumber(totalPlayers) + ' tracked players.<br />(' + formatNumber(playerCount) + ' online.)');
}
});
$(window).on('resize', function() {
updatePercentageBar();
if (historyPlot) {
historyPlot.resize();
historyPlot.setupGrid();
historyPlot.draw();
}
});
});

185
assets/js/sort.js Normal file

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

@ -1,189 +1,160 @@
var MISSING_FAVICON_BASE64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAJNUlEQVR4XtVbe4xU5RX/nXNnH+W1pS8iXaFVwRgaU7ph597ZxZKmD2maGtNSRNFiWyEQLSVaVKRStCgUqo2VVkiR0tiA0ldqW1IbEyzsfDOr2waJ1oYKQpcoWkFeBWe55zTfsEt3l537mLmzbO+/93zfOed3z+s737mEKj86fXodDh+eAse5UlQnkOplAowhohFQHV5kT3RSVU8wcEiJ9rDqHhDtwrFjL9BLLxWqKSJVY3Ntbv4oHOc6X+RqApqZub4cPgKcYqAdRNsAbKFsdn85+wStSQwAnTSpFg0NN/gicx0iN2lBBVD1feM4zjoUCpupo6MrCR4VA6BNTcNQU3OrqC5k5rFJCBW2hwCdrPowiH5CxpwKo6+aBajrzhTm1ax6cSVClLtWgNeY6A7KZn9V7h5lWYCm043KvImAT5XLOMl1CjxDqdQc2rHj9bj7xgZAXfeLQrSRgffFZVZNevH9t7imZg61tf0xDp9YAPiZzApWXRKHwWDS2kDJqsspl1selW8kAHTGDEcOHFjPzF+LunFvOgEOQuQvTGRA9DJ8fy+I3kY+fwLTpjFOnRoG5g8BGA+iSSIyBUTTGGgsk986NmYBARK2PhQAq7x2dm4l4NqwzfooLXIEjmNdZQuy2RcI0DjrLa163mQBrofvf5Ud54Mx129FY+Ms2rrVrygL+On0hjhfXoA3GFgBYEOlKapH8O4aY46I3MNE46ICIUQ/dbLZW8oGwM9kvseq90RhKCI+E61BV9d91NHxnyhr4tIUgRg16k4RWRK1uhTgQceYknGrpAtoJnMNVH8bQ8h7yZj7Y9CXTaotLZeLyFMMXBlpE6Ivl6oVBgTA5nlh3hUn1QnQxcBMMuY3kYSqkEg97z2iup6JZkfY6ih8fzK1t+/rTzsgAOJ5z5ZT5Aw6CACJ532fgTvCQPBFjJPPt/QPxucBYMtbEG0J27DU+8EGwcrhe97qKCAA+AYZs6G37H0AsAcbqat7pdLafrBBUGsJrvvzMHcQon8z80TaufNIDwh9AXDdxSBaVe7X71f8DH5MAHJhgVGAlY4xd58HgE0xMmrUawxcVNK8bapjXi7AdxioCQNq0C3BZoeurl3sOHUBLnqMC4Vx1NFx1NKcswDNZG6G6uOBSqmuolzuLvW8awV4ckiC4Hn3Agg7C9xNxqzsA8AZ1zVBnZxihVcoXNpT5AxVEIqWPHLknqCKUYB9jjGXnAOgu4e3N8SkbyNjHu1NM2RByGTmQnVdoD5EGcpmTdEF1PNsUHggwPePMPOHB6rthyIIRSsYPrwz6AAlwKOOMbcVATiTTj/nMF9VEgCih5xs9vZS74ciCP7ZAunbJXXy/Ved9vbLyPbt5fDhdwIPF0TNlM0+H2RSQw0Ee5QG8NcQt24kTadbwbwjIG0cZGMujnKeH2og+J73r5Cmyg2kmcwCqK4N8P/NTj5/fVjO73k/lEDwPe8XbBsqJX1XV5HveQ8z8K0Aom9SLvejqAB0B9UhUSeo5y0E8MOS+gO/tjX000T0hQAFP03GPBsHgKECgqbTnwHzMwHuvZvOuG67QzSlpIK+f8lA5+gogFxod9Dm5olwnH8EZIK3rAu8zMAVJRUSaaB8/lgUhQeiuZAgqOfZu4u3AyzgFPmuuz+w0WiME6W9PBRTpO0aAQjsT4YDUFdXQ9u3nynXAi5kdihe3NbWngySveou0Jv5YLuDptNjwPxGoAuEBkFgAhnzz0ot4EJYgmYyH4Pq7oAa582qpcGhEBPCWvsC7LYu8BADi0oKTLSQstlHkrKAwbQEdd0lILK3VAM+agshdd35IPpxSTNRfcLJ5W5MGoDBKJbCijzbHyT1vAyAtoBA0ekYU7UJkGoFRm1qqpFU6k1mfm+Adc+iYvNgxIijIXdtnyBj/lYNK6iWJYSVwUVdUqmxPR2h7QA+GWAFaxxjSjYXkgAmaUvwM5mNrDonQKc9jjETzwKQydwJ1WKXdKCnOH5y8mRjtYcWkwLBlsDFSTLAVoID66T6iJPLLewBYLyo7uNebfLzVhHNo2x2fRJfu9opUjOZpVANvqlWdSmXy5+7FzjT3NzmOI4NiKUQO8DHj0+othVUGhO0tXU0fN/eAjcEWHSxH2jf/+9ixPNuArAp5AsP3gxAmZcvvuuuZaIFIXosJmNW9wXApo3a2r1BPTQROc2p1Mepra3kGTtJF4kbEwDYOLY0yJVF5B1OpcZRW9vxPgB0B8PbobomSAkBXuS6ujRt3346SWVL7RUHhCjyCNEKJ5td2kPb93bYTl2I/J2ZxweCoPoE53I3RekURxEqjCYpEAQ4xIXC5T0Xo+dZQLcVfAmqvwwTSoCq1wa9ZUgEBNWbKZf7We99S43I/ImAz0YBgY1Z/P9gCT7RTiebvSp0RKZoBS0tY0V1F6t+IBQE6w5Ec5OaCQzjV44lCHCYHWcy7dx5oP/+pcfkWlo+LyK/DyyOunez52oGvkLGvBKmQBLvfc9bzoCdA4j2qF5DudzvBiIOHJVV110Gou9G4SK+/y6nUivR0PAgbdv2bpQ1cWk0nR4lRPeD6FYGOMp6Ae5zjFlWijZ0Vtj3vMcYmBeFmaXp/pvjAYwe/XhSQFjFwTxPgLtizi4+5hgzP0j2UADssDQ6OzcDmBEVhG4gbD/eTm5tLmdYWu0X9rypAlwHkdnMPCIWf5HNnM/PDmvphwJQDIp2XL6zc20cS+gtrACvQ/U5Bp4vjsur7gfRIRQKp1FbqyAaCZH3g+hSAFco4Knvt5YxIV5ka4cf2Bh70qt8XL5PLnbdZUK0LEpgjPO1kqLt/mFiKeVyJaddImeBUkKp604XkU3lfp2klO2/T7HKE7mR8vk/x+ERyQX6b6hTp16khcJGYv5cHGbVolXgDyTydcrnD8XlURYAPUy6ixLbVv9IXMZJ0Ivvv8rMiyiXe7rc/SoCoBggp02rx+nT84V5UaUzxlGVEBF7ofsDjB69vtJUWzEA56yhqakGtbWzfKJbSLUl6UBZ/HVWZIfDvA51dU8lcWFrZU8MgD7ZorV1HHx/pgJXK+AyMCzq1+2XPk8SYEh1G4ieJGMOlrNPRYVQpQztBQXq65ugevb3eWCCqI4hYATsL/RFP9ITqnq8+Ps8sx1z3QORF1Ff35HUly6lx38BC3SpK3sT2hIAAAAASUVORK5CYII="; export class Tooltip {
constructor () {
var tooltip = $('#tooltip'); this._div = document.getElementById('tooltip')
var lastMojangServiceUpdate;
var publicConfig;
function showCaption(html) {
var tagline = $('#tagline-text');
tagline.stop(true, false);
tagline.html(html);
tagline.slideDown(100);
}
function hideCaption() {
var tagline = $('#tagline-text');
tagline.stop(true, false);
tagline.slideUp(100);
}
function setPublicConfig(json) {
publicConfig = json;
$('#server-container-list').html('');
}
function getServerByField(id, value) {
for (var i = 0; i < publicConfig.servers.length; i++) {
var entry = publicConfig.servers[i];
if (entry[id] === value) {
return entry;
} }
set (x, y, offsetX, offsetY, html) {
this._div.innerHTML = html
// Assign display: block so that the offsetWidth is valid
this._div.style.display = 'block'
// Prevent the div from overflowing the page width
const tooltipWidth = this._div.offsetWidth
// 1.2 is a magic number used to pad the offset to ensure the tooltip
// never gets close or surpasses the page's X width
if (x + offsetX + (tooltipWidth * 1.2) > window.innerWidth) {
x -= tooltipWidth
offsetX *= -1
}
this._div.style.top = (y + offsetY) + 'px'
this._div.style.left = (x + offsetX) + 'px'
}
hide = () => {
this._div.style.display = 'none'
} }
} }
function getServerByIp(ip) { export class Caption {
return getServerByField('ip', ip); constructor () {
} this._div = document.getElementById('status-text')
function getServerByName(name) {
return getServerByField('name', name);
}
function getServerColor(name) {
var server = getServerByName(name);
return server ? server.color : stringToColor(name);
}
// Generate (and set) the HTML that displays Mojang status.
// If nothing is passed, re-render the last update.
// If something is passed, update and then re-render.
function updateMojangServices(currentUpdate) {
if (currentUpdate) {
lastMojangServiceUpdate = currentUpdate;
} }
if (!lastMojangServiceUpdate) { set (text) {
return; this._div.innerText = text
this._div.style.display = 'block'
} }
var keys = Object.keys(lastMojangServiceUpdate); hide () {
this._div.style.display = 'none'
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var status = lastMojangServiceUpdate[key];
// hack: ensure mojang-status is added for alignment, replace existing class to swap status color
$('#mojang-status_' + status.name).attr('class', 'mojang-status mojang-status-' + status.title.toLowerCase());
$('#mojang-status-text_' + status.name).text(status.title);
} }
} }
function findErrorMessage(error) { // Minecraft Java Edition default server port: 25565
if (error.description) { // Minecraft Bedrock Edition default server port: 19132
return error.description; const MINECRAFT_DEFAULT_PORTS = [25565, 19132]
} else if (error.errno) {
return error.errno; export function formatMinecraftServerAddress (ip, port) {
if (port && !MINECRAFT_DEFAULT_PORTS.includes(port)) {
return ip + ':' + port
} }
return ip
} }
function getTimestamp(ms, timeOnly) { // Detect gaps in versions by matching their indexes to knownVersions
var date = new Date(0); export function formatMinecraftVersions (versions, knownVersions) {
if (!versions || !versions.length || !knownVersions || !knownVersions.length) {
return
}
date.setUTCSeconds(ms); let currentVersionGroup = []
const versionGroups = []
return date.toLocaleTimeString(); for (let i = 0; i < versions.length; i++) {
const versionIndex = versions[i]
// Look for value mismatch between the previous index
// Require i > 0 since lastVersionIndex is undefined for i === 0
if (i > 0 && versions[i] - 1 !== versionIndex - 1) {
versionGroups.push(currentVersionGroup)
currentVersionGroup = []
}
currentVersionGroup.push(versionIndex)
}
// Ensure the last versionGroup is always pushed
if (currentVersionGroup.length > 0) {
versionGroups.push(currentVersionGroup)
}
if (versionGroups.length === 0) {
return
}
// Remap individual versionGroups values into named versions
return versionGroups.map(versionGroup => {
const startVersion = knownVersions[versionGroup[0]]
if (versionGroup.length === 1) {
// A versionGroup may contain a single version, only return its name
// This is a cosmetic catch to avoid version labels like 1.0-1.0
return startVersion
} else {
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]]
return startVersion + '-' + endVersion
}
}).join(', ')
} }
function safeName(name) { export function formatTimestamp (millis) {
return name.replace(/ /g, ''); const date = new Date(0)
date.setUTCSeconds(millis / 1000)
return date.toLocaleTimeString()
} }
function renderTooltip(x, y, html) { export function formatDate (millis) {
tooltip.html(html).css({ const date = new Date(0)
top: y, date.setUTCSeconds(millis / 1000)
left: x return date.toLocaleDateString()
}).fadeIn(0);
} }
function hideTooltip() { export function formatPercent (x, over) {
tooltip.hide(); const val = Math.round((x / over) * 100 * 10) / 10
return val + '%'
} }
function formatNumber(x) { export function formatNumber (x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
export function isArrayEqual (a, b) {
if (typeof a === 'undefined' || typeof a !== typeof b) {
return false
}
if (a.length !== b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
export function isObjectEqual (a, b, props) {
if (typeof a === 'undefined' || typeof a !== typeof b) {
return false
}
for (let i = 0; i < props.length; i++) {
const prop = props[i]
if (typeof a[prop] === 'undefined' || typeof a[prop] !== typeof b[prop] || a[prop] !== b[prop]) {
return false
}
}
return true
} }
// From http://detectmobilebrowsers.com/ // From http://detectmobilebrowsers.com/
function isMobileBrowser() { export function isMobileBrowser () {
var check = false; var check = false;
(function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4)))check = true})(navigator.userAgent||navigator.vendor||window.opera); // eslint-disable-next-line no-useless-escape
return check; (function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4)))check = true })(navigator.userAgent || navigator.vendor || window.opera)
} return check
function trimOldPings(data, graphDuration) {
var keys = Object.keys(data);
var timeMs = new Date().getTime();
for (var x = 0; x < keys.length; x++) {
var listing = data[keys[x]];
var toSplice = [];
for (var i = 0; i < listing.length; i++) {
var entry = listing[i];
if (timeMs - entry[0] > graphDuration) {
toSplice.push(i);
}
}
for (var i = 0; i < toSplice.length; i++) {
listing.splice(toSplice[i], 1);
}
}
}
function stringToColor(base) {
var hash;
for (var i = base.length - 1, hash = 0; i >= 0; i--) {
hash = base.charCodeAt(i) + ((hash << 5) - hash);
}
color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16);
return '#' + Array(6 - color.length + 1).join('0') + color;
}
function roundToPoint(val, scale) {
return Math.round(val * scale) / scale;
}
function msToTime(timer) {
var milliseconds = timer % 1000;
timer = (timer - milliseconds) / 1000;
var seconds = timer % 60;
timer = (timer - seconds) / 60;
var minutes = timer % 60;
var hours = (timer - minutes) / 60;
var days = Math.floor(hours / 24);
hours -= days * 24;
var string = '';
// hack: only format days if >1, if === 1 it will format as "24h" instead
if (days > 1) {
string += days + 'd';
} else if (days === 1) {
hours += 24;
}
if (hours > 0) {
string += hours + 'h';
}
if (minutes > 0) {
string += minutes + 'm';
}
if (seconds > 0) {
string += seconds + 's';
}
return string;
} }

@ -1,14 +1,4 @@
{ {
"routes": {
"/": "assets/html/index.html",
"/images/compass.png": "assets/images/compass.png",
"/js/site.js": "assets/js/site.js",
"/js/util.js": "assets/js/util.js",
"/js/graph.js": "assets/js/graph.js",
"/css/main.css": "assets/css/main.css"
},
"faviconOverride": {
},
"site": { "site": {
"port": 8080, "port": 8080,
"ip": "0.0.0.0" "ip": "0.0.0.0"
@ -20,23 +10,5 @@
"connectTimeout": 2500 "connectTimeout": 2500
}, },
"logToDatabase": false, "logToDatabase": false,
"graphDuration": 86400000, "graphDuration": 86400000
"versions": {
"PC": [
4,
5,
47,
107,
210,
315,
335,
393,
477,
575
],
"PE": [
0
]
},
"serverTypesVisible": true
} }

@ -1,3 +1,38 @@
**5** *(Apr 8 2020)*
- New logo!
- Completely rebuilt the frontend's Javascript (heavy optimizations and cleanup!)
- Adds a button for mobile devices to manually request the historical graph
- Adds timestamp to each server's player count record
- Adds the ability to favorite servers so they'll always be sorted first
- Adds "Sort By" option for controlling the server listing sort order
- Adds "Only Favorites" button to graph controls
- Adds ESLint configuration
- New missing favicon icon
- The versions section, and minecraft.json file, have been merged into minecraft_versions.json
- Removes "routes" from config.json. The HTTP server will now serve static assets from dist/
- Added Parcel bundler which bundles the assets/ directory into dist/
- Custom favicons are now served from "favicons/" directory and their configuration moved into servers.json. Paths in servers.json should be updated to reflect their filename without the path.
- Added finalhandler and serve-static dependencies
- Add ```npm run dev``` and ```npm run build``` scripts to package.json
- Added a distinct loading/connection status screen to simplify state management
- publicConfig.json is now sent over the socket connection so the frontend can be safely reloaded on rebooted instances
- Tooltips have been optimized and updated to a more readable design
- Initial page loading has been optimized
- MISSING_FAVICON_BASE64 has been moved to a file, images/missing_favicon.svg to improve caching behavior (and its customizable now!)
- Peak player count labels are formatted using the graphDuration hours and now displays the timestamp seconds
- Fixed favicon payloads being repeatedly sent.
- Fixed the page being broken when connecting to a freshly booted instance
- Fixed graphs starting at 0 player count when a server is initially pinged
- Fixed status text ocassionally not being shown
- Fixed some elements/frontend state not being completely reset on disconnect
- Fixed Minecraft Bedrock Edition servers showing the default port of 19132
- Fixed tooltips overflowing the page width
- Fixed backend bug causing servers to skip some Minecraft versions
- Minor connection blips have a grace period before the UI is updated, this prevents page reshuffle spam when experiencing minor connection issues
- Moved localStorage keys to "minetrack_hidden_servers" since the data structure has been changed
- Removed #validateBootTime loop and logic
- Removed mime dependency
**4.0.5** *(Apr 1 2020)* **4.0.5** *(Apr 1 2020)*
- The frontend will now auto calculate the "24h Peak" label using your configured graphDuration in config.json - The frontend will now auto calculate the "24h Peak" label using your configured graphDuration in config.json

@ -1 +0,0 @@
Compass icon by PixelBuddha: https://www.iconfinder.com/PixelBuddha

40
docs/MIGRATING.md Normal file

@ -0,0 +1,40 @@
# Migrating to Minetrack 5
Minetrack 5 is the first of several upcoming updates designed to address several legacy bugs and design flaws in previous Minetrack versions. As part of this, it modifies some data structures and operational instructions that make the upgrade process more manual than seen previously. This guide covers the distinct differences, with upgrade instructions, to get you started.
## Upgrading Minetrack 5
1. Stop any running instance of Minetrack.
2. If you've cloned the repository, use `git clone https://github.com/Cryptkeeper/Minetrack Minetrack5`. If you've manually downloaded a release or an archive of the repository, download a fresh copy and extract it into a directory named "Minetrack5".
3. Open the directory and execute `npm install --build-from-source`. This will install updated (and new) dependencies needed by the program.
4. If you have `logToDatabase: true` in your `config.json`, make sure to copy your `database.sql` file into the new directory, otherwise you will lose historical server activity and records.
5. Copy your existing `config.json` and `servers.json` files into the new directory.
4. Build your copy of `dist/`.
5. If you have previously configured any `faviconOverride` values within `config.json`, you will need to move them to the updated structure. Create a new directory within your Minetrack folder named `favicons/`.
6. If you have previously configured any `minecraft.json` values not included in the new `minecraft_versions.json` file, you will need to update their structure copy them to the new file.
7. Move your custom favicon images into the directory.
8. Open `servers.json` in your favorite editor.
9. For any server which you have a custom favicon, set the "favicon" field like so:
```
{
"name": "Hypixel",
"ip": "mc.hypixel.net",
"type": "PC",
"favicon": "CustomHypixelFavicon.png"
}
```
(Replacing "CustomHypixelFavicon.png" with your file's name.)
Do **NOT** include the `favicons/` path in the value. For example a file, "my-favicon.png" in the directory `favicons/` should be configured using simply "my-favicon.png".
You may delete the `faviconOverride`, `routes` and `versions` portions of your `config.json`, they are no longer supported features. You may delete the `minecraft.json` file, it has been merged into `minecraft_versions.json`.
You're done!
## Building `dist/`
Minetrack now serves a "bundled" copy of the `assets/` directory, instead of the files directly from disk. This optimizes the delivery speed, but requires an additional step when installing Minetrack or when modifying files within `assets/`.
1. `cd` into your Minetrack directory (if not already there).
2. Execute `npm run build` (`npm run dev` is also available, which skips the minimization step and makes active development easier).
3. Run `ls` to ensure the `dist/` directory has been created.
Whenever modifying files within `assets/`, you will need to re-run the `npm run build` step to reflect the changes. Those expert few of you may wish to dig into Parcel's [watch and serve](https://parceljs.org/cli.html#watch) CLI commands.

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var util = require('./util'); var util = require('./util');
exports.setup = function() { exports.setup = function() {
@ -22,10 +27,10 @@ exports.setup = function() {
}; };
exports.getTotalRecord = function(ip, callback) { exports.getTotalRecord = function(ip, callback) {
db.all("SELECT MAX(playerCount) FROM pings WHERE ip = ?", [ db.all("SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?", [
ip ip
], function(err, data) { ], function(err, data) {
callback(data[0]['MAX(playerCount)']); callback(data[0]['MAX(playerCount)'], data[0]['timestamp']);
}); });
}; };

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var winston = require('winston'); var winston = require('winston');
winston.remove(winston.transports.Console); winston.remove(winston.transports.Console);

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var request = require('request'); var request = require('request');
var logger = require('./logger'); var logger = require('./logger');

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var mcpe_ping = require('mcpe-ping-fixed'); var mcpe_ping = require('mcpe-ping-fixed');
var mcpc_ping = require('mc-ping-updated'); var mcpc_ping = require('mc-ping-updated');
@ -16,7 +21,7 @@ function pingMinecraftPC(host, port, timeout, callback, version) {
var favicon; var favicon;
// Ensure the returned favicon is a data URI // Ensure the returned favicon is a data URI
if (res.favicon.indexOf('data:image/') === 0) { if (res.favicon && res.favicon.indexOf('data:image/') === 0) {
favicon = res.favicon; favicon = res.favicon;
} }
@ -27,7 +32,7 @@ function pingMinecraftPC(host, port, timeout, callback, version) {
}, },
version: parseInt(res.version.protocol), version: parseInt(res.version.protocol),
latency: util.getCurrentTimeMs() - startTime, latency: util.getCurrentTimeMs() - startTime,
favicon favicon: favicon
}); });
} }
}, timeout, version); }, timeout, version);
@ -47,7 +52,6 @@ function pingMinecraftPE(host, port, timeout, callback) {
online: capPlayerCount(host, parseInt(res.currentPlayers)), online: capPlayerCount(host, parseInt(res.currentPlayers)),
max: parseInt(res.maxPlayers) max: parseInt(res.maxPlayers)
}, },
version: res.version,
latency: util.getCurrentTimeMs() - startTime latency: util.getCurrentTimeMs() - startTime
}); });
} }

@ -1,74 +1,32 @@
var http = require('http'); /**
var fs = require('fs'); * THIS IS LEGACY, UNMAINTAINED CODE
var url = require('url'); * IT MAY (AND LIKELY DOES) CONTAIN BUGS
var mime = require('mime'); * USAGE IS NOT RECOMMENDED
var io = require('socket.io'); */
const http = require('http')
var util = require('./util'); const io = require('socket.io')
var logger = require('./logger'); const finalHandler = require('finalhandler')
const serveStatic = require('serve-static')
var config = require('../config.json'); const util = require('./util')
var minecraft = require('../minecraft.json'); const logger = require('./logger')
var servers = require('../servers.json');
var urlMapping = []; const config = require('../config.json')
function setupRoutes() { const distHandler = serveStatic('dist/')
var routeKeys = Object.keys(config.routes); const faviconsHandler = serveStatic('favicons/')
// Map the (static) routes from our config. function onRequest (req, res) {
for (var i = 0; i < routeKeys.length; i++) { logger.log('info', '%s requested: %s', util.getRemoteAddr(req), req.url)
urlMapping[routeKeys[i]] = config.routes[routeKeys[i]]; distHandler(req, res, function () {
} faviconsHandler(req, res, finalHandler(req, res))
})
logger.log('info', 'Routes: %s', routeKeys);
} }
function handleRequest(req, res) { exports.start = function () {
var requestUrl = url.parse(req.url).pathname; const server = http.createServer(onRequest)
server.listen(config.site.port, config.site.ip)
logger.log('info', '%s requested: %s', util.getRemoteAddr(req), requestUrl); exports.io = io.listen(server)
logger.log('info', 'Started on %s:%d', config.site.ip, config.site.port)
if (requestUrl === '/publicConfig.json') {
res.setHeader('Content-Type', 'application/javascript');
var publicConfig = {
graphDuration: config.graphDuration,
servers: servers,
bootTime: util.getBootTime(),
serverTypesVisible: config.serverTypesVisible || false,
minecraftVersions: minecraft.versions
};
res.write('setPublicConfig(' + JSON.stringify(publicConfig) + ');');
res.end();
} else if (requestUrl in urlMapping) {
var file = urlMapping[requestUrl];
res.setHeader('Content-Type', mime.getType(file));
fs.createReadStream(file).pipe(res);
} else {
res.statusCode = 404;
res.write('404');
res.end();
}
} }
exports.start = function() {
setupRoutes();
// Create our tiny little HTTP server.
var server = http.createServer(handleRequest);
server.listen(config.site.port, config.site.ip);
// I don't like this. But it works, I think.
exports.io = io.listen(server);
// Since everything is loaded, let's celebrate!
logger.log('info', 'Started on %s:%d', config.site.ip, config.site.port);
};

@ -1,3 +1,8 @@
/**
* THIS IS LEGACY, UNMAINTAINED CODE
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
* USAGE IS NOT RECOMMENDED
*/
var logger = require('./logger'); var logger = require('./logger');
var dns = require('dns'); var dns = require('dns');
@ -6,8 +11,6 @@ var servers = require('../servers.json');
var serverNameLookup = []; var serverNameLookup = [];
var bootTime;
// Finds a server in servers.json with a matching IP. // Finds a server in servers.json with a matching IP.
// If it finds one, it caches the result for faster future lookups. // If it finds one, it caches the result for faster future lookups.
function getServerNameByIp(ip) { function getServerNameByIp(ip) {
@ -148,16 +151,6 @@ exports.convertServerHistory = function(sqlData) {
return graphData; return graphData;
}; };
exports.getBootTime = function() {
if (!bootTime) {
bootTime = exports.getCurrentTimeMs();
logger.info('Selected %d as boot time.', bootTime);
}
return bootTime;
};
/** /**
* Attempts to resolve Minecraft PC SRV records from DNS, otherwise falling back to the old hostname. * Attempts to resolve Minecraft PC SRV records from DNS, otherwise falling back to the old hostname.
* *

@ -1,16 +0,0 @@
{
"versions": {
"PC": {
"4": "1.7.2",
"5": "1.7.10",
"47": "1.8",
"107": "1.9",
"210": "1.10",
"315": "1.11",
"335": "1.12",
"393": "1.13",
"477": "1.14",
"575": "1.15.1"
}
}
}

44
minecraft_versions.json Normal file

@ -0,0 +1,44 @@
{
"PC": [
{
"name": "1.7.2",
"protocolId": 4
},
{
"name": "1.7.10",
"protocolId": 5
},
{
"name": "1.8",
"protocolId": 47
},
{
"name": "1.9",
"protocolId": 107
},
{
"name": "1.10",
"protocolId": 210
},
{
"name": "1.11",
"protocolId": 315
},
{
"name": "1.12",
"protocolId": 335
},
{
"name": "1.13",
"protocolId": 393
},
{
"name": "1.14",
"protocolId": 477
},
{
"name": "1.15.1",
"protocolId": 575
}
]
}

8010
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,13 +1,14 @@
{ {
"name": "minetrack", "name": "minetrack",
"version": "4.0.5", "version": "5.0.0",
"description": "A Minecraft server tracker that lets you focus on the basics.", "description": "A Minecraft server tracker that lets you focus on the basics.",
"main": "app.js", "main": "app.js",
"dependencies": { "dependencies": {
"finalhandler": "^1.1.2",
"mc-ping-updated": "0.1.1", "mc-ping-updated": "0.1.1",
"mcpe-ping-fixed": "0.0.3", "mcpe-ping-fixed": "0.0.3",
"mime": "2.4.4",
"request": "2.88.2", "request": "2.88.2",
"serve-static": "^1.14.1",
"socket.io": "2.3.0", "socket.io": "2.3.0",
"sqlite3": "4.1.1", "sqlite3": "4.1.1",
"winston": "^2.0.0" "winston": "^2.0.0"
@ -19,10 +20,26 @@
"keywords": [ "keywords": [
"minetrack" "minetrack"
], ],
"author": "Cryptkeeper <hello@npm.nklow.com>", "author": "Nick Krecklow <hello@npm.nklow.com>",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/Cryptkeeper/Minetrack/issues" "url": "https://github.com/Cryptkeeper/Minetrack/issues"
}, },
"homepage": "https://github.com/Cryptkeeper/Minetrack#README" "homepage": "https://github.com/Cryptkeeper/Minetrack#README",
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"babel-eslint": "^10.1.0",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"parcel-bundler": "^1.12.4"
},
"scripts": {
"build": "eslint assets/js/*.js && parcel build assets/html/index.html",
"dev": "parcel build assets/html/index.html --no-minify"
}
} }

@ -2,4 +2,4 @@ apt-get install git npm sqlite3
git clone https://github.com/Cryptkeeper/Minetrack.git git clone https://github.com/Cryptkeeper/Minetrack.git
cd Minetrack/ cd Minetrack/
npm install --build-from-source npm install --build-from-source
sh scripts/start.sh npm run build

@ -8,5 +8,15 @@
"name": "HiveMC", "name": "HiveMC",
"ip": "play.hivemc.com", "ip": "play.hivemc.com",
"type": "PC" "type": "PC"
},
{
"name": "Mineplex",
"ip": "us.mineplex.com",
"type": "PC"
},
{
"name": "CubeCraft",
"ip": "play.cubecraft.net",
"type": "PC"
} }
] ]