Backend cleanup (#146)
* Add ServerRegistration, begin refactoring to match frontend * move graphData logic into ServerRegistration * move ping updates/history into ServerRegistration * start updating main app entry methods * fix default rates.updateMojangStatus * fix record loading delays on freshly booted instances * move database loading logic to method + callback * use data in frontend for type lookup instead of ping * cleanup app.js * reorganize methods to improve flow * avoid useless mojang updates, remove legacy fields * rename legacy fields for consistency * finish restructure around App model * ensure versions are sorted by release order * filter errors sent to frontend to avoid data leaks * fix version listing behavior on frontend * 5.1.0
This commit is contained in:
parent
9eda8d6bdb
commit
4d13965e6b
392
app.js
392
app.js
@ -1,392 +0,0 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var server = require('./lib/server');
|
||||
var ping = require('./lib/ping');
|
||||
var logger = require('./lib/logger');
|
||||
var mojang = require('./lib/mojang_services');
|
||||
var util = require('./lib/util');
|
||||
var db = require('./lib/database');
|
||||
|
||||
var config = require('./config.json');
|
||||
var servers = require('./servers.json');
|
||||
var minecraftVersions = require('./minecraft_versions.json');
|
||||
|
||||
var networkHistory = [];
|
||||
var connectedClients = 0;
|
||||
|
||||
var networkVersions = [];
|
||||
|
||||
const lastFavicons = [];
|
||||
|
||||
var graphData = [];
|
||||
var highestPlayerCount = {};
|
||||
var lastGraphPush = [];
|
||||
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() {
|
||||
for (var i = 0; i < servers.length; i++) {
|
||||
// Make sure we lock our scope.
|
||||
(function(network) {
|
||||
// Asign auto generated color if not present
|
||||
if (!network.color) {
|
||||
network.color = util.stringToColor(network.name);
|
||||
}
|
||||
|
||||
const attemptedVersion = getNextProtocolVersion(network)
|
||||
ping.ping(network.ip, network.port, network.type, config.rates.connectTimeout, function(err, res) {
|
||||
// Handle our ping results, if it succeeded.
|
||||
if (err) {
|
||||
logger.log('error', 'Failed to ping ' + network.ip + ': ' + err.message);
|
||||
}
|
||||
|
||||
// If we have favicon override specified, use it.
|
||||
if (network.favicon) {
|
||||
res.favicon = network.favicon
|
||||
}
|
||||
|
||||
handlePing(network, res, err, attemptedVersion);
|
||||
}, attemptedVersion.protocolId);
|
||||
})(servers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// This is where the result of a ping is feed.
|
||||
// This stores it and converts it to ship to the frontend.
|
||||
function handlePing(network, res, err, attemptedVersion) {
|
||||
// Log our response.
|
||||
if (!networkHistory[network.name]) {
|
||||
networkHistory[network.name] = [];
|
||||
}
|
||||
|
||||
// Update the version list
|
||||
if (!networkVersions[network.name]) {
|
||||
networkVersions[network.name] = [];
|
||||
}
|
||||
|
||||
const serverVersionHistory = networkVersions[network.name]
|
||||
|
||||
// If the result version matches the attempted version, the version is supported
|
||||
if (res && res.version !== undefined) {
|
||||
const indexOf = serverVersionHistory.indexOf(attemptedVersion.protocolIndex)
|
||||
|
||||
// Test indexOf to avoid inserting previously recorded protocolIndex values
|
||||
if (res.version === attemptedVersion.protocolId && indexOf === -1) {
|
||||
serverVersionHistory.push(attemptedVersion.protocolIndex)
|
||||
} else if (res.version !== attemptedVersion.protocolId && indexOf >= 0) {
|
||||
serverVersionHistory.splice(indexOf, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = util.getCurrentTimeMs()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the clients
|
||||
var networkSnapshot = {
|
||||
info: {
|
||||
name: network.name,
|
||||
timestamp: timestamp,
|
||||
type: network.type
|
||||
},
|
||||
versions: serverVersionHistory,
|
||||
recordData: highestPlayerCount[network.ip]
|
||||
};
|
||||
|
||||
if (res) {
|
||||
networkSnapshot.result = res;
|
||||
|
||||
// Only emit updated favicons
|
||||
// Favicons will otherwise be explicitly emitted during the handshake process
|
||||
if (res.favicon) {
|
||||
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) {
|
||||
networkSnapshot.error = err;
|
||||
}
|
||||
|
||||
server.io.sockets.emit('update', networkSnapshot);
|
||||
|
||||
var _networkHistory = networkHistory[network.name];
|
||||
|
||||
// Remove our previous data that we don't need anymore.
|
||||
for (var i = 0; i < _networkHistory.length; i++) {
|
||||
delete _networkHistory[i].versions
|
||||
delete _networkHistory[i].info;
|
||||
}
|
||||
|
||||
_networkHistory.push({
|
||||
error: err,
|
||||
result: res,
|
||||
versions: serverVersionHistory,
|
||||
timestamp: timestamp,
|
||||
info: {
|
||||
ip: network.ip,
|
||||
port: network.port,
|
||||
type: network.type,
|
||||
name: network.name
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we never log too much.
|
||||
if (_networkHistory.length > 72) { // 60/2.5 = 24, so 24 is one minute
|
||||
_networkHistory.shift();
|
||||
}
|
||||
|
||||
// Log it to the database if needed.
|
||||
if (config.logToDatabase) {
|
||||
db.log(network.ip, timestamp, res ? res.players.online : 0);
|
||||
}
|
||||
|
||||
|
||||
// The same mechanic from trimUselessPings is seen here.
|
||||
// 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.
|
||||
if (config.logToDatabase) {
|
||||
if (!lastGraphPush[network.ip] || (timestamp - lastGraphPush[network.ip] >= 60 * 1000 && res) || timestamp - lastGraphPush[network.ip] >= 70 * 1000) {
|
||||
lastGraphPush[network.ip] = timestamp;
|
||||
|
||||
// Don't have too much data!
|
||||
util.trimOldPings(graphData);
|
||||
|
||||
if (!graphData[network.name]) {
|
||||
graphData[network.name] = [];
|
||||
}
|
||||
|
||||
graphData[network.name].push([timestamp, res ? res.players.online : 0]);
|
||||
|
||||
// Send the update.
|
||||
server.io.sockets.emit('updateHistoryGraph', {
|
||||
ip: network.ip,
|
||||
name: network.name,
|
||||
players: (res ? res.players.online : 0),
|
||||
timestamp: timestamp
|
||||
});
|
||||
}
|
||||
|
||||
// Update calculated graph peak regardless if the graph is being updated
|
||||
// This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes
|
||||
var networkData = graphData[network.name];
|
||||
|
||||
if (networkData) {
|
||||
var graphPeakIndex = -1;
|
||||
var graphPeakPlayerCount = 0;
|
||||
for (var i = 0; i < networkData.length; i++) {
|
||||
// [1] refers to the online player count
|
||||
var point = networkData[i][1];
|
||||
if (point > 0 && (graphPeakIndex === -1 || point > graphPeakPlayerCount)) {
|
||||
graphPeakIndex = i;
|
||||
graphPeakPlayerCount = point;
|
||||
}
|
||||
}
|
||||
// Test if a highest index has been selected and has changed from any previous selections
|
||||
var previousPeak = graphPeaks[network.name];
|
||||
// [1] refers to the online player count
|
||||
if (graphPeakIndex !== -1 && (!previousPeak || previousPeak[1] !== graphPeakPlayerCount)) {
|
||||
var graphPeakData = networkData[graphPeakIndex];
|
||||
graphPeaks[network.name] = graphPeakData;
|
||||
|
||||
// Broadcast update event to clients
|
||||
server.io.sockets.emit('updatePeak', {
|
||||
ip: network.ip,
|
||||
name: network.name,
|
||||
players: graphPeakData[1],
|
||||
timestamp: graphPeakData[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start our main loop that does everything.
|
||||
function startMainLoop() {
|
||||
util.setIntervalNoDelay(pingAll, config.rates.pingAll);
|
||||
|
||||
util.setIntervalNoDelay(function() {
|
||||
mojang.update(config.rates.mojangStatusTimeout);
|
||||
|
||||
server.io.sockets.emit('updateMojangServices', mojang.toMessage());
|
||||
}, config.rates.upateMojangStatus);
|
||||
}
|
||||
|
||||
function startServices() {
|
||||
server.start();
|
||||
|
||||
// Track how many people are currently connected.
|
||||
server.io.on('connect', function(client) {
|
||||
// We're good to connect them!
|
||||
connectedClients += 1;
|
||||
|
||||
logger.log('info', '%s connected, total clients: %d', util.getRemoteAddr(client.request), connectedClients);
|
||||
|
||||
// Attach our listeners.
|
||||
client.on('disconnect', function() {
|
||||
connectedClients -= 1;
|
||||
|
||||
logger.log('info', '%s disconnected, total clients: %d', util.getRemoteAddr(client.request), connectedClients);
|
||||
});
|
||||
|
||||
client.on('requestHistoryGraph', function() {
|
||||
if (config.logToDatabase) {
|
||||
// Send them the big 24h graph.
|
||||
client.emit('historyGraph', graphData);
|
||||
|
||||
// Send current peaks, if any
|
||||
if (Object.keys(graphPeaks).length > 0) {
|
||||
client.emit('peaks', graphPeaks);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
client.emit('updateMojangServices', mojang.toMessage());
|
||||
|
||||
// Send each individually, this should look cleaner than waiting for one big array to transfer.
|
||||
for (var i = 0; i < servers.length; i++) {
|
||||
var server = servers[i];
|
||||
|
||||
if (!(server.name in networkHistory) || networkHistory[server.name].length < 1) {
|
||||
// This server hasn't been ping'd yet. Send a hacky placeholder.
|
||||
client.emit('add', [[{
|
||||
error: {
|
||||
description: 'Waiting...',
|
||||
placeholder: true
|
||||
},
|
||||
result: null,
|
||||
timestamp: util.getCurrentTimeMs(),
|
||||
info: {
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
type: server.type,
|
||||
name: server.name
|
||||
}
|
||||
}]]);
|
||||
} else {
|
||||
// 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');
|
||||
});
|
||||
|
||||
startMainLoop();
|
||||
}
|
||||
|
||||
logger.log('info', 'Booting, please wait...');
|
||||
|
||||
if (config.logToDatabase) {
|
||||
// Setup our database.
|
||||
db.setup();
|
||||
|
||||
var timestamp = util.getCurrentTimeMs();
|
||||
|
||||
db.queryPings(config.graphDuration, function(data) {
|
||||
graphData = util.convertServerHistory(data);
|
||||
completedQueries = 0;
|
||||
|
||||
logger.log('info', 'Queried and parsed ping history in %sms', util.getCurrentTimeMs() - timestamp);
|
||||
|
||||
for (var i = 0; i < servers.length; i++) {
|
||||
// Compute graph peak from historical data
|
||||
var networkData = graphData[servers[i].name];
|
||||
if (networkData) {
|
||||
var graphPeakIndex = -1;
|
||||
var graphPeakPlayerCount = 0;
|
||||
for (var x = 0; x < networkData.length; x++) {
|
||||
// [1] refers to the online player count
|
||||
var point = networkData[x][1];
|
||||
if (point > 0 && (graphPeakIndex === -1 || point > graphPeakPlayerCount)) {
|
||||
graphPeakIndex = x;
|
||||
graphPeakPlayerCount = point;
|
||||
}
|
||||
}
|
||||
if (graphPeakIndex !== -1) {
|
||||
graphPeaks[servers[i].name] = networkData[graphPeakIndex];
|
||||
logger.log('info', 'Selected graph peak %d (%s)', networkData[graphPeakIndex][1], servers[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
(function(server) {
|
||||
db.getTotalRecord(server.ip, function(playerCount, timestamp) {
|
||||
logger.log('info', 'Computed total record %s (%d) @ %d', server.ip, playerCount, timestamp);
|
||||
|
||||
highestPlayerCount[server.ip] = {
|
||||
playerCount: playerCount,
|
||||
timestamp: timestamp
|
||||
};
|
||||
|
||||
completedQueries += 1;
|
||||
|
||||
if (completedQueries === servers.length) {
|
||||
startServices();
|
||||
}
|
||||
});
|
||||
})(servers[i]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.log('warn', 'Database logging is not enabled. You can enable it by setting "logToDatabase" to true in config.json. This requires sqlite3 to be installed.');
|
||||
|
||||
startServices();
|
||||
}
|
@ -69,7 +69,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
|
||||
|
||||
if (serverRegistration) {
|
||||
app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.players)
|
||||
app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.playerCount)
|
||||
|
||||
// Only redraw the graph if not mutating hidden data
|
||||
if (serverRegistration.isVisible) {
|
||||
@ -94,7 +94,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
})
|
||||
|
||||
socket.on('updateMojangServices', function (data) {
|
||||
Object.values(data).forEach(app.mojangUpdater.updateServiceStatus)
|
||||
app.mojangUpdater.updateStatus(data)
|
||||
})
|
||||
|
||||
socket.on('setPublicConfig', function (data) {
|
||||
@ -125,7 +125,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
|
||||
|
||||
if (serverRegistration) {
|
||||
serverRegistration.updateServerPeak(data.timestamp, data.players)
|
||||
serverRegistration.updateServerPeak(data.timestamp, data.playerCount)
|
||||
}
|
||||
})
|
||||
|
||||
@ -134,10 +134,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
|
||||
|
||||
if (serverRegistration) {
|
||||
const graphData = data[serverName]
|
||||
const graphPeak = data[serverName]
|
||||
|
||||
// [0] and [1] indexes correspond to flot.js' graphing data structure
|
||||
serverRegistration.updateServerPeak(graphData[0], graphData[1])
|
||||
serverRegistration.updateServerPeak(graphPeak.timestamp, graphPeak.playerCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -1,10 +1,16 @@
|
||||
const MOJANG_STATUS_BASE_CLASS = 'header-button header-button-group'
|
||||
|
||||
export class MojangUpdater {
|
||||
updateServiceStatus (status) {
|
||||
updateStatus (services) {
|
||||
for (const name of Object.keys(services)) {
|
||||
this.updateServiceStatus(name, services[name])
|
||||
}
|
||||
}
|
||||
|
||||
updateServiceStatus (name, title) {
|
||||
// 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
|
||||
document.getElementById('mojang-status_' + name).setAttribute('class', MOJANG_STATUS_BASE_CLASS + ' mojang-status-' + title.toLowerCase())
|
||||
document.getElementById('mojang-status-text_' + name).innerText = title
|
||||
}
|
||||
|
||||
reset () {
|
||||
|
@ -204,7 +204,7 @@ export class ServerRegistration {
|
||||
const versionsElement = document.getElementById('version_' + this.serverId)
|
||||
|
||||
versionsElement.style.display = 'block'
|
||||
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[ping.info.type]) || ''
|
||||
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || ''
|
||||
}
|
||||
|
||||
// Compare against a cached value to avoid empty updates
|
||||
@ -237,8 +237,7 @@ export class ServerRegistration {
|
||||
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'
|
||||
errorElement.innerText = ping.error.message
|
||||
} else if (ping.result) {
|
||||
// Ensure the player-count element is visible and hide the error element
|
||||
playerCountLabelElement.style.display = 'block'
|
||||
|
@ -64,16 +64,14 @@ export function formatMinecraftVersions (versions, knownVersions) {
|
||||
const versionGroups = []
|
||||
|
||||
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) {
|
||||
if (i > 0 && versions[i] - versions[i - 1] !== 1) {
|
||||
versionGroups.push(currentVersionGroup)
|
||||
currentVersionGroup = []
|
||||
}
|
||||
|
||||
currentVersionGroup.push(versionIndex)
|
||||
currentVersionGroup.push(versions[i])
|
||||
}
|
||||
|
||||
// Ensure the last versionGroup is always pushed
|
||||
|
@ -4,7 +4,7 @@
|
||||
"ip": "0.0.0.0"
|
||||
},
|
||||
"rates": {
|
||||
"upateMojangStatus": 5000,
|
||||
"updateMojangStatus": 5000,
|
||||
"mojangStatusTimeout": 3500,
|
||||
"pingAll": 3000,
|
||||
"connectTimeout": 2500
|
||||
|
@ -1,4 +1,7 @@
|
||||
**5** *(Apr 8 2020)*
|
||||
**5.1.0** *(Apr 21 2020)*
|
||||
- Completely rebuilt the backend. This includes several optimizations, code cleanup and syncing fixes. Its code model now pairs nicely with the frontend's Javascript model.
|
||||
|
||||
**5.0.0** *(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
|
||||
|
92
lib/app.js
Normal file
92
lib/app.js
Normal file
@ -0,0 +1,92 @@
|
||||
const Database = require('./database')
|
||||
const MojangUpdater = require('./mojang')
|
||||
const PingController = require('./ping')
|
||||
const Server = require('./server')
|
||||
|
||||
const config = require('../config')
|
||||
const minecraftVersions = require('../minecraft_versions')
|
||||
|
||||
class App {
|
||||
serverRegistrations = []
|
||||
|
||||
constructor () {
|
||||
this.mojangUpdater = new MojangUpdater(this)
|
||||
this.pingController = new PingController(this)
|
||||
this.server = new Server(this.handleClientConnection)
|
||||
}
|
||||
|
||||
loadDatabase (callback) {
|
||||
this.database = new Database(this)
|
||||
|
||||
// Setup database instance
|
||||
this.database.ensureIndexes()
|
||||
|
||||
this.database.loadGraphPoints(config.graphDuration, () => {
|
||||
this.database.loadRecords(callback)
|
||||
})
|
||||
}
|
||||
|
||||
handleReady () {
|
||||
this.server.listen(config.site.ip, config.site.port)
|
||||
|
||||
// Allow individual modules to manage their own task scheduling
|
||||
this.mojangUpdater.schedule()
|
||||
this.pingController.schedule()
|
||||
}
|
||||
|
||||
handleClientConnection = (client) => {
|
||||
if (config.logToDatabase) {
|
||||
client.on('requestHistoryGraph', () => {
|
||||
// Send historical graphData built from all serverRegistrations
|
||||
const graphData = {}
|
||||
const graphPeaks = {}
|
||||
|
||||
this.serverRegistrations.forEach((serverRegistration) => {
|
||||
graphData[serverRegistration.data.name] = serverRegistration.graphData
|
||||
|
||||
// Send current peak, if any
|
||||
const graphPeak = serverRegistration.getGraphPeak()
|
||||
if (graphPeak) {
|
||||
graphPeaks[serverRegistration.data.name] = graphPeak
|
||||
}
|
||||
})
|
||||
|
||||
// Send current peaks, if any
|
||||
// Emit peaks first since graphData may take a while to receive
|
||||
if (Object.keys(graphPeaks).length > 0) {
|
||||
client.emit('peaks', graphPeaks)
|
||||
}
|
||||
|
||||
client.emit('historyGraph', graphData)
|
||||
})
|
||||
}
|
||||
|
||||
client.emit('setPublicConfig', (() => {
|
||||
// Remap minecraftVersion entries into name values
|
||||
const minecraftVersionNames = {}
|
||||
Object.keys(minecraftVersions).forEach(function (key) {
|
||||
minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name)
|
||||
})
|
||||
|
||||
// Send configuration data for rendering the page
|
||||
return {
|
||||
graphDuration: config.graphDuration,
|
||||
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.data),
|
||||
minecraftVersions: minecraftVersionNames,
|
||||
isGraphVisible: config.logToDatabase
|
||||
}
|
||||
})())
|
||||
|
||||
// Send last Mojang update, if any
|
||||
this.mojangUpdater.sendLastUpdate(client)
|
||||
|
||||
// Send pingHistory of all ServerRegistrations
|
||||
client.emit('add', this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()))
|
||||
|
||||
// Always send last
|
||||
// This tells the frontend to do final processing and render
|
||||
client.emit('syncComplete')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = App
|
132
lib/database.js
132
lib/database.js
@ -1,47 +1,105 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var util = require('./util');
|
||||
const sqlite = require('sqlite3')
|
||||
|
||||
exports.setup = function() {
|
||||
var sqlite = require('sqlite3');
|
||||
class Database {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._sql = new sqlite.Database('database.sql')
|
||||
}
|
||||
|
||||
var db = new sqlite.Database('database.sql');
|
||||
ensureIndexes () {
|
||||
this._sql.serialize(() => {
|
||||
this._sql.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)')
|
||||
this._sql.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)')
|
||||
this._sql.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)')
|
||||
})
|
||||
}
|
||||
|
||||
db.serialize(function() {
|
||||
db.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)');
|
||||
});
|
||||
loadGraphPoints (graphDuration, callback) {
|
||||
// Query recent pings
|
||||
const endTime = new Date().getTime()
|
||||
const startTime = endTime - graphDuration
|
||||
|
||||
exports.log = function(ip, timestamp, playerCount) {
|
||||
var insertStatement = db.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)');
|
||||
this.getRecentPings(startTime, endTime, pingData => {
|
||||
const graphPointsByIp = []
|
||||
|
||||
db.serialize(function() {
|
||||
insertStatement.run(timestamp, ip, playerCount);
|
||||
});
|
||||
for (const row of pingData) {
|
||||
// Avoid loading outdated records
|
||||
// This shouldn't happen and is mostly a sanity measure
|
||||
if (startTime - row.timestamp <= graphDuration) {
|
||||
// Load into temporary array
|
||||
// This will be culled prior to being pushed to the serverRegistration
|
||||
let graphPoints = graphPointsByIp[row.ip]
|
||||
if (!graphPoints) {
|
||||
graphPoints = graphPointsByIp[row.ip] = []
|
||||
}
|
||||
|
||||
insertStatement.finalize();
|
||||
};
|
||||
graphPoints.push([row.timestamp, row.playerCount])
|
||||
}
|
||||
}
|
||||
|
||||
exports.getTotalRecord = function(ip, callback) {
|
||||
db.all("SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?", [
|
||||
Object.keys(graphPointsByIp).forEach(ip => {
|
||||
// Match IPs to serverRegistration object
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
if (serverRegistration.data.ip === ip) {
|
||||
const graphPoints = graphPointsByIp[ip]
|
||||
|
||||
// Push the data into the instance and cull if needed
|
||||
serverRegistration.loadGraphPoints(graphPoints)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
callback()
|
||||
})
|
||||
}
|
||||
|
||||
loadRecords (callback) {
|
||||
let completedTasks = 0
|
||||
|
||||
this._app.serverRegistrations.forEach(serverRegistration => {
|
||||
// Find graphPeaks
|
||||
// This pre-computes the values prior to clients connecting
|
||||
serverRegistration.findNewGraphPeak()
|
||||
|
||||
// Query recordData
|
||||
// When complete increment completeTasks to know when complete
|
||||
this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => {
|
||||
serverRegistration.recordData = {
|
||||
playerCount: playerCount,
|
||||
timestamp: timestamp
|
||||
}
|
||||
|
||||
// Check if completedTasks hit the finish value
|
||||
// Fire callback since #readyDatabase is complete
|
||||
if (++completedTasks === this._app.serverRegistrations.length) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getRecentPings (startTime, endTime, callback) {
|
||||
this._sql.all('SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?', [
|
||||
startTime,
|
||||
endTime
|
||||
], (_, data) => callback(data))
|
||||
}
|
||||
|
||||
getRecord (ip, callback) {
|
||||
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [
|
||||
ip
|
||||
], function(err, data) {
|
||||
callback(data[0]['MAX(playerCount)'], data[0]['timestamp']);
|
||||
});
|
||||
};
|
||||
], (_, data) => callback(data[0]['MAX(playerCount)'], data[0].timestamp))
|
||||
}
|
||||
|
||||
exports.queryPings = function(duration, callback) {
|
||||
var currentTime = util.getCurrentTimeMs();
|
||||
insertPing (ip, timestamp, playerCount) {
|
||||
const statement = this._sql.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)')
|
||||
this._sql.serialize(() => {
|
||||
statement.run(timestamp, ip, playerCount)
|
||||
})
|
||||
statement.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
db.all("SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?", [
|
||||
currentTime - duration,
|
||||
currentTime
|
||||
], function(err, data) {
|
||||
callback(data);
|
||||
});
|
||||
};
|
||||
};
|
||||
module.exports = Database
|
||||
|
@ -1,23 +1,17 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var winston = require('winston');
|
||||
const winston = require('winston')
|
||||
|
||||
winston.remove(winston.transports.Console);
|
||||
winston.remove(winston.transports.Console)
|
||||
|
||||
winston.add(winston.transports.File, {
|
||||
filename: 'minetrack.log'
|
||||
});
|
||||
})
|
||||
|
||||
winston.add(winston.transports.Console, {
|
||||
'timestamp': function() {
|
||||
var date = new Date();
|
||||
|
||||
return date.toLocaleTimeString() + ' ' + date.getDate() + '/' + (date.getMonth() + 1) + '/' + date.getFullYear().toString().substring(2, 4);
|
||||
timestamp: () => {
|
||||
const date = new Date()
|
||||
return date.toLocaleTimeString() + ' ' + date.getDate() + '/' + (date.getMonth() + 1) + '/' + date.getFullYear().toString().substring(2, 4)
|
||||
},
|
||||
'colorize': true
|
||||
});
|
||||
colorize: true
|
||||
})
|
||||
|
||||
module.exports = winston;
|
||||
module.exports = winston
|
||||
|
96
lib/mojang.js
Normal file
96
lib/mojang.js
Normal file
@ -0,0 +1,96 @@
|
||||
const request = require('request')
|
||||
|
||||
const logger = require('./logger')
|
||||
|
||||
const config = require('../config')
|
||||
|
||||
const SERVICE_URL_LOOKUP = {
|
||||
'sessionserver.mojang.com': 'Sessions',
|
||||
'authserver.mojang.com': 'Auth',
|
||||
'textures.minecraft.net': 'Skins',
|
||||
'api.mojang.com': 'API'
|
||||
}
|
||||
|
||||
const TITLE_BY_MOJANG_COLOR = {
|
||||
red: 'Offline',
|
||||
yellow: 'Unstable',
|
||||
green: 'Online'
|
||||
}
|
||||
|
||||
class MojangUpdater {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
}
|
||||
|
||||
schedule () {
|
||||
setInterval(this.updateServices, config.rates.updateMojangStatus)
|
||||
|
||||
this.updateServices()
|
||||
}
|
||||
|
||||
updateServices = () => {
|
||||
request({
|
||||
uri: 'https://status.mojang.com/check',
|
||||
method: 'GET',
|
||||
timeout: config.rates.mojangStatusTimeout
|
||||
}, (err, _, body) => {
|
||||
if (err) {
|
||||
logger.log('error', 'Failed to update Mojang services: %s', err.message)
|
||||
|
||||
// Set all services to offline
|
||||
// This may be incorrect, but if mojang.com is offline, it would never otherwise be reflected
|
||||
Object.keys(SERVICE_URL_LOOKUP).forEach(url => {
|
||||
this.handleServiceUpdate(url, 'red')
|
||||
})
|
||||
|
||||
this.pushUpdate()
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(body).forEach(service => {
|
||||
// Each service is formatted as an object with the 0 key being the URL
|
||||
const url = Object.keys(service)[0]
|
||||
this.handleServiceUpdate(url, service[url])
|
||||
})
|
||||
} catch (err) {
|
||||
logger.log('error', 'Failed to parse Mojang response: %s', err.message)
|
||||
}
|
||||
|
||||
this.pushUpdate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pushUpdate () {
|
||||
// Only fire callback when previous state is modified
|
||||
if (this._hasUpdated) {
|
||||
this._hasUpdated = false
|
||||
|
||||
this._app.server.broadcast('updateMojangServices', this._services)
|
||||
}
|
||||
}
|
||||
|
||||
sendLastUpdate (client) {
|
||||
if (this._services) {
|
||||
client.emit('updateMojangServices', this._services)
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceUpdate (url, color) {
|
||||
const service = SERVICE_URL_LOOKUP[url]
|
||||
|
||||
if (service) {
|
||||
const requiredTitle = TITLE_BY_MOJANG_COLOR[color]
|
||||
|
||||
if (!this._services) {
|
||||
this._services = {}
|
||||
}
|
||||
|
||||
if (this._services[service] !== requiredTitle) {
|
||||
this._services[service] = requiredTitle
|
||||
this._hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MojangUpdater
|
@ -1,85 +0,0 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var request = require('request');
|
||||
|
||||
var logger = require('./logger');
|
||||
var util = require('./util');
|
||||
|
||||
var serviceNameLookup = {
|
||||
'sessionserver.mojang.com': 'Sessions',
|
||||
'authserver.mojang.com': 'Auth',
|
||||
'textures.minecraft.net': 'Skins',
|
||||
'api.mojang.com': 'API'
|
||||
};
|
||||
|
||||
var serviceStates = {
|
||||
// Lazy populated.
|
||||
};
|
||||
|
||||
function updateService(name, status) {
|
||||
// Only update if we need to.
|
||||
if (!(name in serviceStates) || serviceStates[name].status !== status) {
|
||||
var newEntry = {
|
||||
name: serviceNameLookup[name], // Send the clean name, not the URL.
|
||||
status: status
|
||||
};
|
||||
|
||||
// If it's an outage, track when it started.
|
||||
if (status === 'yellow'|| status === 'red') {
|
||||
newEntry.startTime = util.getCurrentTimeMs();
|
||||
}
|
||||
|
||||
// Generate a nice title from the color.
|
||||
if (status === 'green') {
|
||||
newEntry.title = 'Online';
|
||||
} else if (status === 'yellow') {
|
||||
newEntry.title = 'Unstable';
|
||||
} else if (status === 'red') {
|
||||
newEntry.title = 'Offline';
|
||||
} else {
|
||||
throw new Error('Unknown Mojang status: ' + status);
|
||||
}
|
||||
|
||||
// Wipe the old status in favor of the new one.
|
||||
serviceStates[name] = newEntry;
|
||||
}
|
||||
}
|
||||
|
||||
exports.update = function(timeout) {
|
||||
request({
|
||||
uri: 'http://status.mojang.com/check',
|
||||
method: 'GET',
|
||||
timeout: timeout
|
||||
}, function(err, res, body) {
|
||||
if (err) {
|
||||
logger.log('error', 'Failed to update Mojang services: %s', JSON.stringify(err));
|
||||
} else {
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
|
||||
for (var i = 0; i < body.length; i++) {
|
||||
var service = body[i];
|
||||
var name = Object.keys(service)[0]; // Because they return an array of object, we have to do this :(
|
||||
|
||||
// If it's not in the lookup, we don't care about it.
|
||||
if (name in serviceNameLookup) {
|
||||
updateService(name, service[name]);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('debug', 'Updated Mojang services: %s', JSON.stringify(serviceStates));
|
||||
} catch(err) {
|
||||
// Catch anything weird that can happen, since things probably will.
|
||||
logger.log('error', 'Failed to parse Mojang\'s response: %s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.toMessage = function() {
|
||||
// This is what we send to the clients.
|
||||
return serviceStates;
|
||||
};
|
176
lib/ping.js
176
lib/ping.js
@ -1,85 +1,143 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var mcpe_ping = require('mcpe-ping-fixed');
|
||||
var mcpc_ping = require('mc-ping-updated');
|
||||
const dns = require('dns')
|
||||
|
||||
var logger = require('./logger');
|
||||
var util = require('./util');
|
||||
const minecraftJavaPing = require('mc-ping-updated')
|
||||
const minecraftBedrockPing = require('mcpe-ping-fixed')
|
||||
|
||||
// This is a wrapper function for mc-ping-updated, mainly used to convert the data structure of the result.
|
||||
function pingMinecraftPC(host, port, timeout, callback, version) {
|
||||
var startTime = util.getCurrentTimeMs();
|
||||
const logger = require('./logger')
|
||||
|
||||
mcpc_ping(host, port, function(err, res) {
|
||||
const config = require('../config')
|
||||
|
||||
function ping (host, port, type, timeout, callback, version) {
|
||||
switch (type) {
|
||||
case 'PC':
|
||||
unfurlSrv(host, port, (host, port) => {
|
||||
minecraftJavaPing(host, port || 25565, (err, res) => {
|
||||
if (err) {
|
||||
callback(err, null);
|
||||
callback(err)
|
||||
} else {
|
||||
// Remap our JSON into our custom structure.
|
||||
var favicon;
|
||||
const payload = {
|
||||
players: {
|
||||
online: capPlayerCount(host, parseInt(res.players.online))
|
||||
},
|
||||
version: parseInt(res.version.protocol)
|
||||
}
|
||||
|
||||
// Ensure the returned favicon is a data URI
|
||||
if (res.favicon && res.favicon.indexOf('data:image/') === 0) {
|
||||
favicon = res.favicon;
|
||||
if (res.favicon && res.favicon.startsWith('data:image/')) {
|
||||
payload.favicon = res.favicon
|
||||
}
|
||||
|
||||
callback(null, payload)
|
||||
}
|
||||
}, timeout, version)
|
||||
})
|
||||
break
|
||||
|
||||
case 'PE':
|
||||
minecraftBedrockPing(host, port || 19132, (err, res) => {
|
||||
if (err) {
|
||||
callback(err)
|
||||
} else {
|
||||
callback(null, {
|
||||
players: {
|
||||
online: capPlayerCount(host, parseInt(res.players.online)),
|
||||
max: parseInt(res.players.max)
|
||||
},
|
||||
version: parseInt(res.version.protocol),
|
||||
latency: util.getCurrentTimeMs() - startTime,
|
||||
favicon: favicon
|
||||
});
|
||||
online: capPlayerCount(host, parseInt(res.currentPlayers))
|
||||
}
|
||||
})
|
||||
}
|
||||
}, timeout)
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported type: ' + type)
|
||||
}
|
||||
}, timeout, version);
|
||||
}
|
||||
|
||||
// This is a wrapper function for mcpe-ping, mainly used to convert the data structure of the result.
|
||||
function pingMinecraftPE(host, port, timeout, callback) {
|
||||
var startTime = util.getCurrentTimeMs();
|
||||
|
||||
mcpe_ping(host, port || 19132, function(err, res) {
|
||||
if (err) {
|
||||
callback(err, null);
|
||||
function unfurlSrv (hostname, port, callback) {
|
||||
dns.resolveSrv('_minecraft._tcp.' + hostname, (_, records) => {
|
||||
if (!records || records.length < 1) {
|
||||
callback(hostname, port)
|
||||
} else {
|
||||
// Remap our JSON into our custom structure.
|
||||
callback(err, {
|
||||
players: {
|
||||
online: capPlayerCount(host, parseInt(res.currentPlayers)),
|
||||
max: parseInt(res.maxPlayers)
|
||||
},
|
||||
latency: util.getCurrentTimeMs() - startTime
|
||||
});
|
||||
callback(records[0].name, records[0].port)
|
||||
}
|
||||
}, timeout);
|
||||
})
|
||||
}
|
||||
|
||||
// player count can be up to 1^32-1, which is a massive scale and destroys browser performance when rendering graphs
|
||||
// Artificially cap and warn to prevent propogating garbage
|
||||
function capPlayerCount(host, playerCount) {
|
||||
const maxPlayerCount = 250000;
|
||||
function capPlayerCount (host, playerCount) {
|
||||
const maxPlayerCount = 250000
|
||||
|
||||
if (playerCount !== Math.min(playerCount, maxPlayerCount)) {
|
||||
logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount);
|
||||
return maxPlayerCount;
|
||||
logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount)
|
||||
|
||||
return maxPlayerCount
|
||||
} else if (playerCount !== Math.max(playerCount, 0)) {
|
||||
logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount);
|
||||
return 0;
|
||||
logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount)
|
||||
|
||||
return 0
|
||||
}
|
||||
return playerCount;
|
||||
return playerCount
|
||||
}
|
||||
|
||||
exports.ping = function(host, port, type, timeout, callback, version) {
|
||||
if (type === 'PC') {
|
||||
util.unfurlSRV(host, port, function(host, port){
|
||||
pingMinecraftPC(host, port || 25565, timeout, callback, version);
|
||||
})
|
||||
} else if (type === 'PE') {
|
||||
pingMinecraftPE(host, port || 19132, timeout, callback);
|
||||
} else {
|
||||
throw new Error('Unsupported type: ' + type);
|
||||
class PingController {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
}
|
||||
};
|
||||
|
||||
schedule () {
|
||||
setInterval(this.pingAll, config.rates.pingAll)
|
||||
|
||||
this.pingAll()
|
||||
}
|
||||
|
||||
pingAll = () => {
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
const version = serverRegistration.getNextProtocolVersion()
|
||||
|
||||
ping(serverRegistration.data.ip, serverRegistration.data.port, serverRegistration.data.type, config.rates.connectTimeout, (err, resp) => {
|
||||
if (err) {
|
||||
logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message)
|
||||
}
|
||||
|
||||
this.handlePing(serverRegistration, resp, err, version)
|
||||
}, version.protocolId)
|
||||
}
|
||||
}
|
||||
|
||||
handlePing (serverRegistration, resp, err, version) {
|
||||
const timestamp = new Date().getTime()
|
||||
|
||||
this._app.server.broadcast('update', serverRegistration.getUpdate(timestamp, resp, err, version))
|
||||
|
||||
serverRegistration.addPing(timestamp, resp)
|
||||
|
||||
if (config.logToDatabase) {
|
||||
const playerCount = resp ? resp.players.online : 0
|
||||
|
||||
// Log to database
|
||||
this._app.database.insertPing(serverRegistration.data.ip, timestamp, playerCount)
|
||||
|
||||
if (serverRegistration.addGraphPoint(resp !== undefined, playerCount, timestamp)) {
|
||||
this._app.server.broadcast('updateHistoryGraph', {
|
||||
name: serverRegistration.data.name,
|
||||
playerCount: playerCount,
|
||||
timestamp: timestamp
|
||||
})
|
||||
}
|
||||
|
||||
// Update calculated graph peak regardless if the graph is being updated
|
||||
// This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes
|
||||
if (serverRegistration.findNewGraphPeak()) {
|
||||
const graphPeak = serverRegistration.getGraphPeak()
|
||||
|
||||
this._app.server.broadcast('updatePeak', {
|
||||
name: serverRegistration.data.name,
|
||||
playerCount: graphPeak.playerCount,
|
||||
timestamp: graphPeak.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PingController
|
||||
|
@ -1,32 +1,64 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
const http = require('http')
|
||||
|
||||
const io = require('socket.io')
|
||||
const finalHandler = require('finalhandler')
|
||||
const finalHttpHandler = require('finalhandler')
|
||||
const serveStatic = require('serve-static')
|
||||
const io = require('socket.io')
|
||||
|
||||
const util = require('./util')
|
||||
const logger = require('./logger')
|
||||
|
||||
const config = require('../config.json')
|
||||
function getRemoteAddr (req) {
|
||||
return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress
|
||||
}
|
||||
|
||||
const distHandler = serveStatic('dist/')
|
||||
const faviconsHandler = serveStatic('favicons/')
|
||||
class Server {
|
||||
constructor (clientSocketHandler) {
|
||||
this._clientSocketHandler = clientSocketHandler
|
||||
this._connectedSockets = 0
|
||||
|
||||
function onRequest (req, res) {
|
||||
logger.log('info', '%s requested: %s', util.getRemoteAddr(req), req.url)
|
||||
distHandler(req, res, function () {
|
||||
faviconsHandler(req, res, finalHandler(req, res))
|
||||
this._http = http.createServer(this.handleHttpRequest)
|
||||
|
||||
this._distServeStatic = serveStatic('dist/')
|
||||
this._faviconsServeStatic = serveStatic('favicons/')
|
||||
}
|
||||
|
||||
listen (host, port) {
|
||||
this._http.listen(port, host)
|
||||
|
||||
this._io = io.listen(this._http)
|
||||
this._io.on('connect', this.handleClientSocket)
|
||||
|
||||
logger.log('info', 'Started on %s:%d', host, port)
|
||||
}
|
||||
|
||||
broadcast (event, payload) {
|
||||
this._io.sockets.emit(event, payload)
|
||||
}
|
||||
|
||||
handleHttpRequest = (req, res) => {
|
||||
logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url)
|
||||
|
||||
// Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic
|
||||
// If faviconServeStatic fails, pass to finalHttpHandler to terminate
|
||||
this._distServeStatic(req, res, () => {
|
||||
this._faviconsServeStatic(req, res, finalHttpHandler(req, res))
|
||||
})
|
||||
}
|
||||
|
||||
handleClientSocket = (client) => {
|
||||
this._connectedSockets++
|
||||
|
||||
logger.log('info', '%s connected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets)
|
||||
|
||||
// Bind disconnect event for logging
|
||||
client.on('disconnect', () => {
|
||||
this._connectedSockets--
|
||||
|
||||
logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets)
|
||||
})
|
||||
|
||||
// Pass client off to proxy handler
|
||||
this._clientSocketHandler(client)
|
||||
}
|
||||
}
|
||||
|
||||
exports.start = function () {
|
||||
const server = http.createServer(onRequest)
|
||||
server.listen(config.site.port, config.site.ip)
|
||||
exports.io = io.listen(server)
|
||||
logger.log('info', 'Started on %s:%d', config.site.ip, config.site.port)
|
||||
}
|
||||
module.exports = Server
|
||||
|
279
lib/servers.js
Normal file
279
lib/servers.js
Normal file
@ -0,0 +1,279 @@
|
||||
const config = require('../config')
|
||||
const minecraftVersions = require('../minecraft_versions')
|
||||
|
||||
class ServerRegistration {
|
||||
lastFavicon
|
||||
versions = []
|
||||
recordData
|
||||
graphData = []
|
||||
|
||||
constructor (data) {
|
||||
this.data = data
|
||||
this._pingHistory = []
|
||||
this._hasInitialRecordData = false
|
||||
}
|
||||
|
||||
getUpdate (timestamp, resp, err, version) {
|
||||
const update = {
|
||||
info: {
|
||||
name: this.data.name,
|
||||
timestamp: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (resp) {
|
||||
if (resp.version && this.updateProtocolVersionCompat(resp.version, version.protocolId, version.protocolIndex)) {
|
||||
// Append an updated version listing
|
||||
update.versions = this.versions
|
||||
}
|
||||
|
||||
// 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 && (!this._hasInitialRecordData || !this.recordData || resp.players.online > this.recordData.playerCount)) {
|
||||
// For instances with existing data, recordData will be defined at boot
|
||||
// This causes initial updates to NOT include recordData since it hasn't changed
|
||||
// This flag force includes it to avoid "late loading"
|
||||
this._hasInitialRecordData = true
|
||||
|
||||
this.recordData = {
|
||||
playerCount: resp.players.online,
|
||||
timestamp: timestamp
|
||||
}
|
||||
|
||||
// Append an updated recordData
|
||||
update.recordData = this.recordData
|
||||
}
|
||||
|
||||
// Compare against this.data.favicon to support favicon overrides
|
||||
const newFavicon = resp.favicon || this.data.favicon
|
||||
if (this.updateFavicon(newFavicon)) {
|
||||
// Append an updated favicon
|
||||
update.favicon = newFavicon
|
||||
}
|
||||
|
||||
// Append a result object
|
||||
// This filters out unwanted data from resp
|
||||
update.result = {
|
||||
players: resp.players
|
||||
}
|
||||
} else if (err) {
|
||||
// Append a filtered copy of err
|
||||
// This ensures any unintended data is not leaked
|
||||
update.error = this.filterError(err)
|
||||
}
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
addPing (timestamp, resp) {
|
||||
const ping = {
|
||||
timestamp: timestamp
|
||||
}
|
||||
|
||||
if (resp) {
|
||||
// Append a result object
|
||||
// This filters out unwanted data from resp
|
||||
ping.result = {
|
||||
players: {
|
||||
online: resp.players.online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._pingHistory.push(ping)
|
||||
|
||||
// Trim pingHistory to avoid memory leaks
|
||||
if (this._pingHistory.length > 72) {
|
||||
this._pingHistory.shift()
|
||||
}
|
||||
}
|
||||
|
||||
getPingHistory () {
|
||||
if (this._pingHistory.length > 0) {
|
||||
const pingHistory = []
|
||||
|
||||
for (let i = 0; i < this._pingHistory.length - 1; i++) {
|
||||
pingHistory[i] = this._pingHistory[i]
|
||||
}
|
||||
|
||||
// Insert the latest update manually into the array
|
||||
// This is a mutated copy of the last update to contain live metadata
|
||||
// The metadata is used by the frontend for rendering
|
||||
const lastPing = this._pingHistory[this._pingHistory.length - 1]
|
||||
|
||||
const payload = {
|
||||
info: {
|
||||
name: this.data.name
|
||||
},
|
||||
timestamp: lastPing.timestamp,
|
||||
versions: this.versions,
|
||||
recordData: this.recordData,
|
||||
favicon: this.lastFavicon
|
||||
}
|
||||
|
||||
// Conditionally append to avoid defining fields with undefined values
|
||||
if (lastPing.result) {
|
||||
payload.result = lastPing.result
|
||||
} else if (lastPing.error) {
|
||||
payload.error = lastPing.error
|
||||
}
|
||||
|
||||
// Insert the reconstructed update as the last entry
|
||||
// pingHistory is already sorted during its copy from _pingHistory
|
||||
pingHistory.push(payload)
|
||||
|
||||
return pingHistory
|
||||
}
|
||||
|
||||
return [{
|
||||
error: {
|
||||
message: 'Waiting...',
|
||||
placeholder: true
|
||||
},
|
||||
timestamp: new Date().getTime(),
|
||||
info: {
|
||||
name: this.data.name
|
||||
},
|
||||
recordData: this.recordData
|
||||
}]
|
||||
}
|
||||
|
||||
loadGraphPoints (points) {
|
||||
// Filter pings so each result is a minute apart
|
||||
const minutePoints = []
|
||||
let lastTimestamp = 0
|
||||
|
||||
for (const point of points) {
|
||||
// 0 is the index of the timestamp
|
||||
if (point[0] - lastTimestamp >= 60 * 1000) {
|
||||
// This check tries to smooth out randomly dropped pings
|
||||
// By default only filter pings that are online (playerCount > 0)
|
||||
// This will keep looking forward until it finds a ping that is online
|
||||
// If it can't find one within a reasonable timeframe, it will select a failed ping
|
||||
if (point[0] - lastTimestamp >= 120 * 1000 || point[1] > 0) {
|
||||
minutePoints.push(point)
|
||||
lastTimestamp = point[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minutePoints.length > 0) {
|
||||
this.graphData = minutePoints
|
||||
|
||||
// Select the last entry to use for lastGraphDataPush
|
||||
this._lastGraphDataPush = minutePoints[minutePoints.length - 1][0]
|
||||
}
|
||||
}
|
||||
|
||||
addGraphPoint (isSuccess, playerCount, timestamp) {
|
||||
// If the ping failed, then to avoid destroying the graph, ignore it
|
||||
// However if it's been too long since the last successful ping, push it anyways
|
||||
if (this._lastGraphDataPush) {
|
||||
const timeSince = timestamp - this._lastGraphDataPush
|
||||
if ((isSuccess && timeSince < 60 * 1000) || (!isSuccess && timeSince < 70 * 1000)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
this.graphData.push([timestamp, playerCount])
|
||||
this._lastGraphDataPush = timestamp
|
||||
|
||||
// Trim old graphPoints according to graphDuration
|
||||
for (let i = 1; i < this.graphData.length; i++) {
|
||||
// Find a break point where i - 1 is too old and i is new
|
||||
if (timestamp - this.graphData[i - 1][0] > config.graphDuration && timestamp - this.graphData[i] <= config.graphDuration) {
|
||||
this.graphData.splice(0, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
findNewGraphPeak () {
|
||||
let index = -1
|
||||
for (let i = 0; i < this.graphData.length; i++) {
|
||||
const point = this.graphData[i]
|
||||
if (index === -1 || point[1] > this.graphData[index][1]) {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
const lastGraphPeakIndex = this._graphPeakIndex
|
||||
this._graphPeakIndex = index
|
||||
return index !== lastGraphPeakIndex
|
||||
} else {
|
||||
this._graphPeakIndex = undefined
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getGraphPeak () {
|
||||
if (this._graphPeakIndex === undefined) {
|
||||
return
|
||||
}
|
||||
const graphPeak = this.graphData[this._graphPeakIndex]
|
||||
return {
|
||||
playerCount: graphPeak[1],
|
||||
timestamp: graphPeak[0]
|
||||
}
|
||||
}
|
||||
|
||||
updateFavicon (favicon) {
|
||||
if (favicon && favicon !== this.lastFavicon) {
|
||||
this.lastFavicon = favicon
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
updateProtocolVersionCompat (incomingId, outgoingId, protocolIndex) {
|
||||
// If the result version matches the attempted version, the version is supported
|
||||
const isSuccess = incomingId === outgoingId
|
||||
const indexOf = this.versions.indexOf(protocolIndex)
|
||||
|
||||
// Test indexOf to avoid inserting previously recorded protocolIndex values
|
||||
if (isSuccess && indexOf < 0) {
|
||||
this.versions.push(protocolIndex)
|
||||
|
||||
// Sort versions in ascending order
|
||||
// This matches protocol ids to Minecraft versions release order
|
||||
this.versions.sort((a, b) => a - b)
|
||||
|
||||
return true
|
||||
} else if (!isSuccess && indexOf >= 0) {
|
||||
this.versions.splice(indexOf, 1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getNextProtocolVersion () {
|
||||
const protocolVersions = minecraftVersions[this.data.type]
|
||||
if (typeof this._nextProtocolIndex === 'undefined' || this._nextProtocolIndex + 1 >= protocolVersions.length) {
|
||||
this._nextProtocolIndex = 0
|
||||
} else {
|
||||
this._nextProtocolIndex++
|
||||
}
|
||||
return {
|
||||
protocolId: protocolVersions[this._nextProtocolIndex].protocolId,
|
||||
protocolIndex: this._nextProtocolIndex
|
||||
}
|
||||
}
|
||||
|
||||
filterError (err) {
|
||||
// Attempt to match to the first possible value
|
||||
for (const key of ['message', 'description', 'errno']) {
|
||||
if (err[key]) {
|
||||
return {
|
||||
message: err[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ServerRegistration
|
174
lib/util.js
174
lib/util.js
@ -1,174 +0,0 @@
|
||||
/**
|
||||
* THIS IS LEGACY, UNMAINTAINED CODE
|
||||
* IT MAY (AND LIKELY DOES) CONTAIN BUGS
|
||||
* USAGE IS NOT RECOMMENDED
|
||||
*/
|
||||
var logger = require('./logger');
|
||||
var dns = require('dns');
|
||||
|
||||
var config = require('../config.json');
|
||||
var servers = require('../servers.json');
|
||||
|
||||
var serverNameLookup = [];
|
||||
|
||||
// Finds a server in servers.json with a matching IP.
|
||||
// If it finds one, it caches the result for faster future lookups.
|
||||
function getServerNameByIp(ip) {
|
||||
var lookupName = serverNameLookup[ip];
|
||||
|
||||
if (lookupName) {
|
||||
return lookupName;
|
||||
}
|
||||
|
||||
for (var i = 0; i < servers.length; i++) {
|
||||
var entry = servers[i];
|
||||
|
||||
if (entry.ip === ip) {
|
||||
serverNameLookup[entry.ip] = entry.name;
|
||||
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a list of configured server IPs from servers.json
|
||||
function getServerIps() {
|
||||
var ips = [];
|
||||
|
||||
for (var i = 0; i < servers.length; i++) {
|
||||
ips.push(servers[i].ip);
|
||||
}
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
// This method is a monstrosity.
|
||||
// Since we loaded ALL pings from the database, we need to filter out the pings so each entry is a minute apart.
|
||||
// This is done by iterating over the list, since the time between each ping can be completely arbitrary.
|
||||
function trimUselessPings(data) {
|
||||
var keys = Object.keys(data);
|
||||
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var listing = data[keys[i]];
|
||||
var lastTimestamp = 0;
|
||||
|
||||
var filteredListing = [];
|
||||
|
||||
for (var x = 0; x < listing.length; x++) {
|
||||
var entry = listing[x];
|
||||
|
||||
// 0 is the index of the timestamp.
|
||||
// See the convertPingsToGraph method.
|
||||
if (entry[0] - lastTimestamp >= 60 * 1000) {
|
||||
// This second check tries to smooth out randomly dropped pings.
|
||||
// By default we only want entries that are online (playerCount > 0).
|
||||
// This way we'll keep looking forward until we find one that is online.
|
||||
// However if we can't find one within a reasonable timeframe, select the sucky one.
|
||||
if (entry[0] - lastTimestamp >= 120 * 1000 || entry[1] > 0) {
|
||||
filteredListing.push(entry);
|
||||
|
||||
lastTimestamp = entry[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data[keys[i]] = filteredListing;
|
||||
}
|
||||
}
|
||||
|
||||
exports.trimOldPings = function(data) {
|
||||
var keys = Object.keys(data);
|
||||
|
||||
var timeMs = exports.getCurrentTimeMs();
|
||||
|
||||
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] > config.graphDuration) {
|
||||
toSplice.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < toSplice.length; i++) {
|
||||
listing.splice(toSplice[i], 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.getCurrentTimeMs = function() {
|
||||
return new Date().getTime();
|
||||
};
|
||||
|
||||
exports.stringToColor = function(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;
|
||||
}
|
||||
|
||||
exports.setIntervalNoDelay = function(func, delay) {
|
||||
var task = setInterval(func, delay);
|
||||
|
||||
func();
|
||||
|
||||
return task;
|
||||
};
|
||||
|
||||
exports.convertServerHistory = function(sqlData) {
|
||||
var serverIps = getServerIps();
|
||||
var graphData = {};
|
||||
|
||||
var startTime = exports.getCurrentTimeMs();
|
||||
|
||||
for (var i = 0; i < sqlData.length; i++) {
|
||||
var entry = sqlData[i];
|
||||
|
||||
if (serverIps.indexOf(entry.ip) === -1) continue;
|
||||
|
||||
var name = getServerNameByIp(entry.ip);
|
||||
|
||||
if (!graphData[name]) graphData[name] = [];
|
||||
graphData[name].push([entry.timestamp, entry.playerCount]);
|
||||
}
|
||||
|
||||
// Break it into minutes.
|
||||
trimUselessPings(graphData);
|
||||
|
||||
// Drop old data.
|
||||
exports.trimOldPings(graphData);
|
||||
|
||||
logger.info('Parsed ' + sqlData.length + ' ping records in ' + (exports.getCurrentTimeMs() - startTime) + 'ms');
|
||||
|
||||
return graphData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to resolve Minecraft PC SRV records from DNS, otherwise falling back to the old hostname.
|
||||
*
|
||||
* @param hostname hostname to check
|
||||
* @param port port to pass to callback if required
|
||||
* @param callback function with a hostname and port parameter
|
||||
*/
|
||||
exports.unfurlSRV = function(hostname, port, callback) {
|
||||
dns.resolveSrv("_minecraft._tcp."+hostname, function (err, records) {
|
||||
if(!records||records.length<=0) {
|
||||
callback(hostname, port);
|
||||
return;
|
||||
}
|
||||
callback(records[0].name, records[0].port);
|
||||
})
|
||||
};
|
||||
|
||||
exports.getRemoteAddr = function(req) {
|
||||
let remoteAddress = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
return remoteAddress;
|
||||
};
|
36
main.js
Normal file
36
main.js
Normal file
@ -0,0 +1,36 @@
|
||||
const App = require('./lib/app')
|
||||
const ServerRegistration = require('./lib/servers')
|
||||
|
||||
const logger = require('./lib/logger')
|
||||
|
||||
const config = require('./config')
|
||||
const servers = require('./servers')
|
||||
|
||||
const app = new App()
|
||||
|
||||
servers.forEach(server => {
|
||||
// Assign a generated color for each servers.json entry if not manually defined
|
||||
// These will be passed to the frontend for use in rendering
|
||||
if (!server.color) {
|
||||
let hash = 0
|
||||
for (let i = server.name.length - 1; i >= 0; i--) {
|
||||
hash = server.name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
|
||||
const color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16)
|
||||
server.color = '#' + Array(6 - color.length + 1).join('0') + color
|
||||
}
|
||||
|
||||
// Init a ServerRegistration instance of each entry in servers.json
|
||||
app.serverRegistrations.push(new ServerRegistration(server))
|
||||
})
|
||||
|
||||
if (!config.logToDatabase) {
|
||||
logger.log('warn', 'Database logging is not enabled. You can enable it by setting "logToDatabase" to true in config.json. This requires sqlite3 to be installed.')
|
||||
|
||||
app.handleReady()
|
||||
} else {
|
||||
app.loadDatabase(() => {
|
||||
app.handleReady()
|
||||
})
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "minetrack",
|
||||
"version": "5.0.0",
|
||||
"version": "5.1.0",
|
||||
"description": "A Minecraft server tracker that lets you focus on the basics.",
|
||||
"main": "app.js",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
"finalhandler": "^1.1.2",
|
||||
"mc-ping-updated": "0.1.1",
|
||||
|
@ -1,5 +1,5 @@
|
||||
while true;
|
||||
do
|
||||
node app.js
|
||||
node main.js
|
||||
sleep 5
|
||||
done
|
Loading…
Reference in New Issue
Block a user