Merge pull request #166 from Cryptkeeper/uplot

5.5.0 release preview
This commit is contained in:
Nick Krecklow 2020-05-20 23:10:56 -05:00 committed by GitHub
commit 00347ed0a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 693 additions and 434 deletions

@ -1,8 +1,7 @@
{ {
"env": { "env": {
"browser": true, "browser": true,
"es6": true, "es6": true
"jquery": true
}, },
"extends": [ "extends": [
"standard" "standard"

@ -1,4 +1,7 @@
@import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300); @import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300);
@import url(uplot/dist/uPlot.min.css);
@import url(../css/icons.css); @import url(../css/icons.css);
* { * {
@ -263,24 +266,6 @@ footer a:hover {
display: inline-block; display: inline-block;
} }
.server .column-status .server-is-favorite {
cursor: pointer;
color: var(--color-gold);
}
.server .column-status .server-is-favorite:hover::before {
content: "\f006";
}
.server .column-status .server-is-not-favorite {
cursor: pointer;
color: var(--background-color);
}
.server .column-status .server-is-not-favorite:hover {
color: var(--color-gold);
}
.server .column-status .server-error { .server .column-status .server-error {
display: none; display: none;
color: #e74c3c; color: #e74c3c;
@ -298,11 +283,27 @@ footer a:hover {
} }
.server .column-graph { .server .column-graph {
float: right;
height: 100px; height: 100px;
width: 400px; width: 400px;
margin-right: -3px; }
margin-bottom: 5px;
/* Favorite icons */
.server-is-favorite {
cursor: pointer;
color: var(--color-gold);
}
.server-is-favorite:hover::before {
content: "\f006";
}
.server-is-not-favorite {
cursor: pointer;
color: var(--background-color);
}
.server-is-not-favorite:hover {
color: var(--color-gold);
} }
/* Highlighted values */ /* Highlighted values */
@ -331,19 +332,8 @@ footer a:hover {
} }
/* Historical graph */ /* Historical graph */
#big-graph-mobile-load-request { #big-graph {
background: var(--background-color); padding-right: 65px;
color: var(--text-color);
padding: 10px 0;
text-align: center;
display: none;
width: 100%;
margin-bottom: 10px;
}
#big-graph-mobile-load-request a {
display: inline-block;
color: var(--text-color);
} }
#big-graph, #big-graph-controls, #big-graph-checkboxes { #big-graph, #big-graph-controls, #big-graph-checkboxes {
@ -461,3 +451,9 @@ footer a:hover {
margin-bottom: 20px; margin-bottom: 20px;
} }
} }
/* uPlot.css overrides */
.uplot .select {
background: var(--color-blue);
opacity: 0.3;
}

@ -10,6 +10,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<script defer src="../js/main.js"></script>
<title>Minetrack</title> <title>Minetrack</title>
</head> </head>
@ -59,13 +61,6 @@
</div> </div>
</header> </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"> <div id="big-graph-controls">
@ -88,11 +83,6 @@
<span class="icon-code"></span> Powered by open source software - <a href="https://github.com/Cryptkeeper/Minetrack">make it your own!</a> <span class="icon-code"></span> Powered by open source software - <a href="https://github.com/Cryptkeeper/Minetrack">make it your own!</a>
</footer> </footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.slim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flot/0.8.3/jquery.flot.min.js"></script>
<script src="../js/main.js" defer></script>
</body> </body>
</html> </html>

@ -1,54 +1,26 @@
import { formatNumber, formatTimestamp, isMobileBrowser } from './util' import uPlot from 'uplot'
import { RelativeScale } from './scale'
import { formatNumber, formatTimestampSeconds } from './util'
import { uPlotTooltipPlugin } from './tooltip'
import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites'
export const HISTORY_GRAPH_OPTIONS = {
series: {
shadowSize: 0
},
xaxis: {
font: {
color: '#E3E3E3'
},
show: false
},
yaxis: {
show: true,
ticks: 20,
minTickSize: 10,
tickLength: 10,
tickFormatter: formatNumber,
font: {
color: '#E3E3E3'
},
labelWidth: -5,
min: 0
},
grid: {
hoverable: true,
color: '#696969'
},
legend: {
show: false
}
}
const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers' const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers'
const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites' const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites'
export class GraphDisplayManager { export class GraphDisplayManager {
// Only emit graph data request if not on mobile due to graph data size
isVisible = !isMobileBrowser()
constructor (app) { constructor (app) {
this._app = app this._app = app
this._graphData = [] this._graphData = []
this._graphTimestamps = []
this._hasLoadedSettings = false this._hasLoadedSettings = false
this._initEventListenersOnce = false this._initEventListenersOnce = false
this._showOnlyFavorites = false this._showOnlyFavorites = false
} }
addGraphPoint (serverId, timestamp, playerCount) { addGraphPoint (timestamp, playerCounts) {
if (!this._hasLoadedSettings) { if (!this._hasLoadedSettings) {
// _hasLoadedSettings is controlled by #setGraphData // _hasLoadedSettings is controlled by #setGraphData
// It will only be true once the context has been loaded and initial payload received // It will only be true once the context has been loaded and initial payload received
@ -57,15 +29,31 @@ export class GraphDisplayManager {
return return
} }
const graphData = this._graphData[serverId] this._graphTimestamps.push(timestamp)
// Push the new data from the method call request for (let i = 0; i < playerCounts.length; i++) {
graphData.push([timestamp, playerCount]) this._graphData[i].push(playerCounts[i])
// Trim any outdated entries by filtering the array into a new array
if (graphData.length > this._app.publicConfig.graphMaxLength) {
graphData.shift()
} }
// Trim all data arrays to only the relevant portion
// This keeps it in sync with backend data structures
const graphMaxLength = this._app.publicConfig.graphMaxLength
if (this._graphTimestamps.length > graphMaxLength) {
this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength)
}
for (const series of this._graphData) {
if (series.length > graphMaxLength) {
series.splice(0, series.length - graphMaxLength)
}
}
// Paint updated data structure
this._plotInstance.setData([
this._graphTimestamps,
...this._graphData
])
} }
loadLocalStorage () { loadLocalStorage () {
@ -126,23 +114,27 @@ export class GraphDisplayManager {
} }
} }
// Converts the backend data into the schema used by flot.js
getVisibleGraphData () { getVisibleGraphData () {
return Object.keys(this._graphData) return this._app.serverRegistry.getServerRegistrations()
.map(Number) .filter(serverRegistration => serverRegistration.isVisible)
.map(serverId => this._app.serverRegistry.getServerRegistration(serverId)) .map(serverRegistration => this._graphData[serverRegistration.serverId])
.filter(serverRegistration => serverRegistration !== undefined && serverRegistration.isVisible)
.map(serverRegistration => {
return {
data: this._graphData[serverRegistration.serverId],
yaxis: 1,
label: serverRegistration.data.name,
color: serverRegistration.data.color
}
})
} }
buildPlotInstance (graphData) { getPlotSize () {
return {
width: Math.max(window.innerWidth, 800) * 0.9,
height: 400
}
}
getGraphDataPoint (serverId, index) {
const graphData = this._graphData[serverId]
if (graphData && index < graphData.length && typeof graphData[index] === 'number') {
return graphData[index]
}
}
buildPlotInstance (timestamps, data) {
// Lazy load settings from localStorage, if any and if enabled // Lazy load settings from localStorage, if any and if enabled
if (!this._hasLoadedSettings) { if (!this._hasLoadedSettings) {
this._hasLoadedSettings = true this._hasLoadedSettings = true
@ -150,12 +142,124 @@ export class GraphDisplayManager {
this.loadLocalStorage() this.loadLocalStorage()
} }
this._graphData = graphData this._graphTimestamps = timestamps
this._graphData = data
// Explicitly define a height so flot.js can rescale the Y axis const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => {
document.getElementById('big-graph').style.height = '400px' return {
scale: 'Players',
stroke: serverRegistration.data.color,
width: 2,
value: (_, raw) => formatNumber(raw) + ' Players',
show: serverRegistration.isVisible,
spanGaps: true,
points: {
show: false
}
}
})
this._plotInstance = $.plot('#big-graph', this.getVisibleGraphData(), HISTORY_GRAPH_OPTIONS) const tickCount = 10
// eslint-disable-next-line new-cap
this._plotInstance = new uPlot({
plugins: [
uPlotTooltipPlugin((pos, id) => {
if (pos) {
let text = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => serverRegistration.isVisible)
.sort((a, b) => {
if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1
}
const aPoint = this.getGraphDataPoint(a.serverId, id)
const bPoint = this.getGraphDataPoint(b.serverId, id)
if (typeof aPoint === typeof bPoint) {
if (typeof aPoint === 'undefined') {
return 0
}
} else {
return typeof aPoint === 'number' ? -1 : 1
}
return bPoint - aPoint
})
.map(serverRegistration => {
const point = this.getGraphDataPoint(serverRegistration.serverId, id)
let serverName = serverRegistration.data.name
if (serverRegistration.isFavorite) {
serverName = '<span class="' + this._app.favoritesManager.getIconClass(true) + '"></span> ' + serverName
}
if (typeof point === 'number') {
return serverName + ': ' + formatNumber(point)
} else {
return serverName + ': -'
}
}).join('<br>')
text += '<br><br><strong>' + formatTimestampSeconds(this._graphTimestamps[id]) + '</strong>'
this._app.tooltip.set(pos.left, pos.top, 10, 10, text)
} else {
this._app.tooltip.hide()
}
})
],
...this.getPlotSize(),
cursor: {
y: false
},
series: [
{
},
...series
],
axes: [
{
font: '14px "Open Sans", sans-serif',
stroke: '#FFF',
grid: {
show: false
},
space: 60
},
{
font: '14px "Open Sans", sans-serif',
stroke: '#FFF',
size: 65,
grid: {
stroke: '#333',
width: 1
},
split: () => {
const visibleGraphData = this.getVisibleGraphData()
const [, max, scale] = RelativeScale.scaleMatrix(visibleGraphData, tickCount)
const ticks = RelativeScale.generateTicks(0, max, scale)
return ticks
}
}
],
scales: {
Players: {
auto: false,
range: () => {
const visibleGraphData = this.getVisibleGraphData()
const [, scaledMax] = RelativeScale.scaleMatrix(visibleGraphData, tickCount)
return [0, scaledMax]
}
}
},
legend: {
show: false
}
}, [
this._graphTimestamps,
...this._graphData
], document.getElementById('big-graph'))
// Show the settings-toggle element // Show the settings-toggle element
document.getElementById('settings-toggle').style.display = 'inline-block' document.getElementById('settings-toggle').style.display = 'inline-block'
@ -166,11 +270,12 @@ export class GraphDisplayManager {
// This may cause unnessecary localStorage updates, but its a rare and harmless outcome // This may cause unnessecary localStorage updates, but its a rare and harmless outcome
this.updateLocalStorage() this.updateLocalStorage()
// Fire calls to the provided graph instance // Copy application state into the series data used by uPlot
// This allows flot.js to manage redrawing and creates a helper method to reduce code duplication for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
this._plotInstance.setData(this.getVisibleGraphData()) this._plotInstance.series[serverRegistration.serverId + 1].show = serverRegistration.isVisible
this._plotInstance.setupGrid() }
this._plotInstance.draw()
this._plotInstance.redraw()
} }
requestResize () { requestResize () {
@ -189,11 +294,7 @@ export class GraphDisplayManager {
} }
resize = () => { resize = () => {
if (this._plotInstance) { this._plotInstance.setSize(this.getPlotSize())
this._plotInstance.resize()
this._plotInstance.setupGrid()
this._plotInstance.draw()
}
// undefine value so #clearTimeout is not called // undefine value so #clearTimeout is not called
// This is safe even if #resize is manually called since it removes the pending work // This is safe even if #resize is manually called since it removes the pending work
@ -204,21 +305,6 @@ export class GraphDisplayManager {
this._resizeRequestTimeout = undefined 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 () { initEventListeners () {
if (!this._initEventListenersOnce) { if (!this._initEventListenersOnce) {
this._initEventListenersOnce = true this._initEventListenersOnce = true
@ -231,8 +317,6 @@ export class GraphDisplayManager {
}) })
} }
$('#big-graph').bind('plothover', this.handlePlotHover)
// These listeners should be bound each #initEventListeners call since they are for newly created elements // These listeners should be bound each #initEventListeners call since they are for newly created elements
document.querySelectorAll('.graph-control').forEach((element) => { document.querySelectorAll('.graph-control').forEach((element) => {
element.addEventListener('click', this.handleServerButtonClick, false) element.addEventListener('click', this.handleServerButtonClick, false)
@ -317,8 +401,15 @@ export class GraphDisplayManager {
} }
reset () { reset () {
// Destroy graphs and unload references
// uPlot#destroy handles listener de-registration, DOM reset, etc
if (this._plotInstance) {
this._plotInstance.destroy()
this._plotInstance = undefined
}
this._graphTimestamps = []
this._graphData = [] this._graphData = []
this._plotInstance = undefined
this._hasLoadedSettings = false this._hasLoadedSettings = false
// Fire #clearTimeout if the timeout is currently defined // Fire #clearTimeout if the timeout is currently defined
@ -333,10 +424,5 @@ export class GraphDisplayManager {
document.getElementById('big-graph-controls').style.display = 'none' document.getElementById('big-graph-controls').style.display = 'none'
document.getElementById('settings-toggle').style.display = 'none' document.getElementById('settings-toggle').style.display = 'none'
const graphElement = document.getElementById('big-graph')
graphElement.innerHTML = ''
graphElement.removeAttribute('style')
} }
} }

@ -11,12 +11,4 @@ document.addEventListener('DOMContentLoaded', () => {
// Delegate to GraphDisplayManager which can check if the resize is necessary // Delegate to GraphDisplayManager which can check if the resize is necessary
app.graphDisplayManager.requestResize() app.graphDisplayManager.requestResize()
}, false) }, false)
document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () {
// Send a graph data request to the backend
app.socketManager.sendHistoryGraphRequest()
// Hide the activation link to avoid multiple requests
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
}, false)
}, false) }, false)

84
assets/js/scale.js Normal file

@ -0,0 +1,84 @@
export class RelativeScale {
static scale (data, tickCount) {
const [min, max] = RelativeScale.calculateBounds(data)
let factor = 1
while (true) {
const scale = Math.pow(10, factor)
const scaledMin = min - (min % scale)
const scaledMax = max + (max % scale === 0 ? 0 : (scale - (max % scale)))
const ticks = (scaledMax - scaledMin) / scale
if (ticks < tickCount + 1) {
return [scaledMin, scaledMax, scale]
} else {
// Too many steps between min/max, increase factor and try again
factor++
}
}
}
static scaleMatrix (data, tickCount) {
let max = Number.MIN_VALUE
for (const row of data) {
let testMax = Number.MIN_VALUE
for (const point of row) {
if (point > testMax) {
testMax = point
}
}
if (testMax > max) {
max = testMax
}
}
if (max === Number.MIN_VALUE) {
max = 0
}
return RelativeScale.scale([0, max], tickCount)
}
static generateTicks (min, max, step) {
const ticks = []
for (let i = min; i <= max; i += step) {
ticks.push(i)
}
return ticks
}
static calculateBounds (data) {
if (data.length === 0) {
return [0, 0]
} else {
let min = Number.MAX_VALUE
let max = Number.MIN_VALUE
for (const point of data) {
if (typeof point === 'number') {
if (point > max) {
max = point
}
if (point < min) {
min = point
}
}
}
if (min === Number.MAX_VALUE) {
min = 0
}
if (max === Number.MIN_VALUE) {
max = 0
}
return [min, max]
}
}
}

@ -1,37 +1,12 @@
import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util' import uPlot from 'uplot'
import { RelativeScale } from './scale'
import { formatNumber, formatTimestampSeconds, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util'
import { uPlotTooltipPlugin } from './tooltip'
import MISSING_FAVICON from '../images/missing_favicon.svg' 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 { export class ServerRegistry {
constructor (app) { constructor (app) {
this._app = app this._app = app
@ -88,37 +63,107 @@ export class ServerRegistration {
this._app = app this._app = app
this.serverId = serverId this.serverId = serverId
this.data = data this.data = data
this._graphData = [] this._graphData = [[], []]
this._failedSequentialPings = 0 this._failedSequentialPings = 0
} }
addGraphPoints (points, timestampPoints) { addGraphPoints (points, timestampPoints) {
for (let i = 0; i < points.length; i++) { this._graphData = [
const point = points[i] timestampPoints.slice(),
const timestamp = timestampPoints[i] points
this._graphData.push([timestamp, point]) ]
}
} }
buildPlotInstance () { buildPlotInstance () {
this._plotInstance = $.plot('#chart_' + this.serverId, [this._graphData], SERVER_GRAPH_OPTIONS) const tickCount = 4
// eslint-disable-next-line new-cap
this._plotInstance = new uPlot({
plugins: [
uPlotTooltipPlugin((pos, id) => {
if (pos) {
const playerCount = this._graphData[1][id]
if (typeof playerCount !== 'number') {
this._app.tooltip.hide()
} else {
const text = formatNumber(playerCount) + ' Players<br>' + formatTimestampSeconds(this._graphData[0][id])
this._app.tooltip.set(pos.left, pos.top, 10, 10, text)
}
} else {
this._app.tooltip.hide()
}
})
],
height: 100,
width: 400,
cursor: {
y: false,
drag: {
setScale: false,
x: false,
y: false
},
sync: {
key: 'minetrack-server',
setSeries: true
}
},
series: [
{},
{
scale: 'Players',
stroke: '#E9E581',
width: 2,
value: (_, raw) => formatNumber(raw) + ' Players',
spanGaps: true,
points: {
show: false
}
}
],
axes: [
{
show: false
},
{
ticks: {
show: false
},
font: '14px "Open Sans", sans-serif',
stroke: '#A3A3A3',
size: 55,
grid: {
stroke: '#333',
width: 1
},
split: () => {
const [min, max, scale] = RelativeScale.scale(this._graphData[1], tickCount)
const ticks = RelativeScale.generateTicks(min, max, scale)
return ticks
}
}
],
scales: {
Players: {
auto: false,
range: () => {
const [scaledMin, scaledMax] = RelativeScale.scale(this._graphData[1], tickCount)
return [scaledMin, scaledMax]
}
}
},
legend: {
show: false
}
}, this._graphData, document.getElementById('chart_' + this.serverId))
} }
handlePing (payload, timestamp) { handlePing (payload, timestamp) {
if (typeof payload.playerCount !== 'undefined') { if (typeof payload.playerCount === 'number') {
this.playerCount = payload.playerCount this.playerCount = payload.playerCount
// Only update graph for successful pings
// This intentionally pauses the server graph when pings begin to fail
this._graphData.push([timestamp, this.playerCount])
// Trim graphData to within the max length by shifting out the leading elements
if (this._graphData.length > this._app.publicConfig.serverGraphMaxLength) {
this._graphData.shift()
}
this.redraw()
// Reset failed ping counter to ensure the next connection error // Reset failed ping counter to ensure the next connection error
// doesn't instantly retrigger a layout change // doesn't instantly retrigger a layout change
this._failedSequentialPings = 0 this._failedSequentialPings = 0
@ -129,13 +174,20 @@ export class ServerRegistration {
this.playerCount = 0 this.playerCount = 0
} }
} }
}
redraw () { // Use payload.playerCount so nulls WILL be pushed into the graphing data
this._graphData[0].push(timestamp)
this._graphData[1].push(payload.playerCount)
// Trim graphData to within the max length by shifting out the leading elements
for (const series of this._graphData) {
if (series.length > this._app.publicConfig.serverGraphMaxLength) {
series.shift()
}
}
// Redraw the plot instance // Redraw the plot instance
this._plotInstance.setData([this._graphData]) this._plotInstance.setData(this._graphData)
this._plotInstance.setupGrid()
this._plotInstance.draw()
} }
updateServerRankIndex (rankIndex) { updateServerRankIndex (rankIndex) {
@ -144,68 +196,68 @@ export class ServerRegistration {
document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1) document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1)
} }
updateServerPeak (data) { _renderValue (prefix, handler) {
const peakLabelElement = document.getElementById('peak_' + this.serverId) const labelElement = document.getElementById(prefix + '_' + this.serverId)
// Always set label once any peak data has been received labelElement.style.display = 'block'
peakLabelElement.style.display = 'block'
const peakValueElement = document.getElementById('peak-value_' + this.serverId) const valueElement = document.getElementById(prefix + '-value_' + this.serverId)
const targetElement = valueElement || labelElement
peakValueElement.innerText = formatNumber(data.playerCount) if (targetElement) {
peakLabelElement.title = 'At ' + formatTimestamp(data.timestamp) if (typeof handler === 'function') {
handler(targetElement)
} else {
targetElement.innerText = handler
}
}
}
this.lastPeakData = data _hideValue (prefix) {
const element = document.getElementById(prefix + '_' + this.serverId)
element.style.display = 'none'
} }
updateServerStatus (ping, minecraftVersions) { updateServerStatus (ping, minecraftVersions) {
if (ping.versions) { if (ping.versions) {
const versionsElement = document.getElementById('version_' + this.serverId) this._renderValue('version', formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '')
versionsElement.style.display = 'block'
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || ''
} }
if (ping.recordData) { if (ping.recordData) {
// Always set label once any record data has been received this._renderValue('record', (element) => {
const recordLabelElement = document.getElementById('record_' + this.serverId) if (ping.recordData.timestamp > 0) {
element.innerText = formatNumber(ping.recordData.playerCount) + ' (' + formatDate(ping.recordData.timestamp) + ')'
element.title = 'At ' + formatDate(ping.recordData.timestamp) + ' ' + formatTimestampSeconds(ping.recordData.timestamp)
} else {
element.innerText = formatNumber(ping.recordData.playerCount)
}
})
recordLabelElement.style.display = 'block' this.lastRecordData = ping.recordData
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 > 0) {
recordValueElement.innerHTML = formatNumber(recordData.playerCount) + ' (' + formatDate(recordData.timestamp) + ')'
recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestamp(recordData.timestamp)
} else {
recordValueElement.innerText = formatNumber(recordData.playerCount)
}
this.lastRecordData = recordData
} }
if (ping.graphPeakData) { if (ping.graphPeakData) {
this.updateServerPeak(ping.graphPeakData) this._renderValue('peak', (element) => {
element.innerText = formatNumber(ping.graphPeakData.playerCount)
element.title = 'At ' + formatTimestampSeconds(ping.graphPeakData.timestamp)
})
this.lastPeakData = ping.graphPeakData
} }
const playerCountLabelElement = document.getElementById('player-count_' + this.serverId)
const errorElement = document.getElementById('error_' + this.serverId)
if (ping.error) { if (ping.error) {
// Hide any visible player-count and show the error element this._hideValue('player-count')
playerCountLabelElement.style.display = 'none' this._renderValue('error', ping.error.message)
errorElement.style.display = 'block' } else if (typeof ping.playerCount !== 'number') {
this._hideValue('player-count')
errorElement.innerText = ping.error.message // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object
} else if (typeof ping.playerCount !== 'undefined') { // In this case playerCount will safely be null, so provide a generic error message instead
// Ensure the player-count element is visible and hide the error element this._renderValue('error', 'Failed to ping')
playerCountLabelElement.style.display = 'block' } else if (typeof ping.playerCount === 'number') {
errorElement.style.display = 'none' this._hideValue('error')
this._renderValue('player-count', formatNumber(ping.playerCount))
document.getElementById('player-count-value_' + this.serverId).innerText = formatNumber(ping.playerCount)
} }
// An updated favicon has been sent, update the src // An updated favicon has been sent, update the src
@ -259,8 +311,6 @@ export class ServerRegistration {
} }
initEventListeners () { initEventListeners () {
$('#chart_' + this.serverId).bind('plothover', this._app.graphDisplayManager.handlePlotHover)
document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => { document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => {
this._app.favoritesManager.handleFavoriteButtonClick(this) this._app.favoritesManager.handleFavoriteButtonClick(this)
}, false) }, false)

@ -38,9 +38,6 @@ export class SocketManager {
this._app.caption.set('Disconnected due to error.') this._app.caption.set('Disconnected due to error.')
} }
// Reset modified DOM structures
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
// Schedule socket reconnection attempt // Schedule socket reconnection attempt
this.scheduleReconnect() this.scheduleReconnect()
} }
@ -54,17 +51,12 @@ export class SocketManager {
// Display the main page component // Display the main page component
// Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn // Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn
// Otherwise flot.js will cause visual alignment bugs
this._app.setPageReady(true) this._app.setPageReady(true)
// Allow the graphDisplayManager to control whether or not the historical graph is loaded // Allow the graphDisplayManager to control whether or not the historical graph is loaded
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload // Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
if (this._app.publicConfig.isGraphVisible) { if (this._app.publicConfig.isGraphVisible) {
if (this._app.graphDisplayManager.isVisible) { this.sendHistoryGraphRequest()
this.sendHistoryGraphRequest()
} else {
document.getElementById('big-graph-mobile-load-request').style.display = 'block'
}
} }
payload.servers.forEach((serverPayload, serverId) => { payload.servers.forEach((serverPayload, serverId) => {
@ -82,8 +74,6 @@ export class SocketManager {
break break
case 'updateServers': { case 'updateServers': {
let requestGraphRedraw = false
for (let serverId = 0; serverId < payload.updates.length; serverId++) { for (let serverId = 0; serverId < payload.updates.length; serverId++) {
// The backend may send "update" events prior to receiving all "add" events // The backend may send "update" events prior to receiving all "add" events
// A server has only been added once it's ServerRegistration is defined // A server has only been added once it's ServerRegistration is defined
@ -93,27 +83,15 @@ export class SocketManager {
if (serverRegistration) { if (serverRegistration) {
serverRegistration.handlePing(serverUpdate, payload.timestamp) serverRegistration.handlePing(serverUpdate, payload.timestamp)
serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions) serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions)
} }
// Use update payloads to conditionally append data to graph
// Skip any incoming updates if the graph is disabled
if (serverUpdate.updateHistoryGraph && this._app.graphDisplayManager.isVisible) {
// Update may not be successful, safely append 0 points
const playerCount = serverUpdate.playerCount || 0
this._app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount)
// Only redraw the graph if not mutating hidden data
if (serverRegistration.isVisible) {
requestGraphRedraw = true
}
}
} }
// Run redraw tasks after handling bulk updates // Bulk add playerCounts into graph during #updateHistoryGraph
if (requestGraphRedraw) { if (payload.updateHistoryGraph) {
this._app.graphDisplayManager.addGraphPoint(payload.timestamp, Object.values(payload.updates).map(update => update.playerCount))
// Run redraw tasks after handling bulk updates
this._app.graphDisplayManager.redraw() this._app.graphDisplayManager.redraw()
} }
@ -129,11 +107,7 @@ export class SocketManager {
} }
case 'historyGraph': { case 'historyGraph': {
// Consider the graph visible since a payload has been received this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData)
// This is used for the manual graph load request behavior
this._app.graphDisplayManager.isVisible = true
this._app.graphDisplayManager.buildPlotInstance(payload.graphData)
// Build checkbox elements for graph controls // Build checkbox elements for graph controls
let lastRowCounter = 0 let lastRowCounter = 0

@ -1,5 +1,3 @@
import { isArrayEqual } from './util'
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{ {
getName: () => 'Players', getName: () => 'Players',
@ -164,8 +162,21 @@ export class SortController {
// This avoids DOM updates and graphs being redrawn // This avoids DOM updates and graphs being redrawn
const sortedServerIds = sortedServers.map(server => server.serverId) const sortedServerIds = sortedServers.map(server => server.serverId)
if (isArrayEqual(sortedServerIds, this._lastSortedServers)) { if (this._lastSortedServers) {
return let allMatch = true
// Test if the arrays have actually changed
// No need to length check, they are the same source data each time
for (let i = 0; i < sortedServerIds.length; i++) {
if (sortedServerIds[i] !== this._lastSortedServers[i]) {
allMatch = false
break
}
}
if (allMatch) {
return
}
} }
this._lastSortedServers = sortedServerIds this._lastSortedServers = sortedServerIds
@ -176,7 +187,10 @@ export class SortController {
// Update the DOM structure // Update the DOM structure
sortedServers.forEach(function (serverRegistration) { sortedServers.forEach(function (serverRegistration) {
$('#container_' + serverRegistration.serverId).appendTo('#server-list') const parentElement = document.getElementById('server-list')
const serverElement = document.getElementById('container_' + serverRegistration.serverId)
parentElement.appendChild(serverElement)
// Set the ServerRegistration's rankIndex to its indexOf the normal sort // Set the ServerRegistration's rankIndex to its indexOf the normal sort
serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration)) serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration))

28
assets/js/tooltip.js Normal file

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

@ -99,15 +99,15 @@ export function formatMinecraftVersions (versions, knownVersions) {
}).join(', ') }).join(', ')
} }
export function formatTimestamp (millis) { export function formatTimestampSeconds (secs) {
const date = new Date(0) const date = new Date(0)
date.setUTCSeconds(millis / 1000) date.setUTCSeconds(secs)
return date.toLocaleTimeString() return date.toLocaleTimeString()
} }
export function formatDate (millis) { export function formatDate (secs) {
const date = new Date(0) const date = new Date(0)
date.setUTCSeconds(millis / 1000) date.setUTCSeconds(secs)
return date.toLocaleDateString() return date.toLocaleDateString()
} }
@ -119,26 +119,3 @@ export function formatPercent (x, over) {
export 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
}
// From http://detectmobilebrowsers.com/
export function isMobileBrowser () {
var check = false;
// eslint-disable-next-line no-useless-escape
(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
}

@ -1,3 +1,20 @@
**5.5.0** *(May 20 2020)*
**IMPORTANT**
This update moves ping timestamps to a shared timestamp per round. Meaning that when pinging servers, each will share the same timestamp for that series of pings. The legacy backend used a timestamp per ping per series of pings. This means after updating Minetrack, the historical graph may render slightly inaccurate for the first 24 hours (or whatever your config.json->graphDuration is), and will automatically correct itself as it receives new updates. Don't worry.
- Replaces flot.js charts with uPlot charts. This new chart library renders much quicker and supports a reduced data format. This results in ~1/12th the bandwidth use when sending the historical graph.
- Removed jQuery (flot.js required this dependency). Between removing flot.js and jQuery, the page size has been reduced by 100KB (33%)!
- New historical graph tooltip design to better compare multiple servers.
- Historical graph now supports click dragging to zoom in to a custom time frame. Double click to reset.
- Historical graph now displays time markers along the bottom.
- All graphs now have horizontal ticks to improve readability.
- Graphs will now display gaps (null) when the ping fails. This removes legacy graph smoothing code and prevents 0 player count pings messing up graph scales.
- Graphs will now render the same on initial page load as they will after being open for a while. This fixes a long standing bug where the frontend ignored 0 player count pings in updates but not on initial load.
- Removes the mobile browser detection/manual historical graph load request. It is now automatically loaded given its smaller size.
Faster, smaller, more features.
**5.4.3** *(May 14 2020)* **5.4.3** *(May 14 2020)*
- Added support for the optional field `config->skipSrvTimeout` in `config.json`. If a configured server does not return a valid response when unfurling potential SRV records, it will avoid re-unfurling SRV records for this duration in milliseconds. Use a value of `0` to disable this feature altogether. - Added support for the optional field `config->skipSrvTimeout` in `config.json`. If a configured server does not return a valid response when unfurling potential SRV records, it will avoid re-unfurling SRV records for this duration in milliseconds. Use a value of `0` to disable this feature altogether.
- Removes support for the `config->performance->skipUnfurlSrv` and `config->performance->unfurlSrvCacheTtl` fields in `config.json - Removes support for the `config->performance->skipUnfurlSrv` and `config->performance->unfurlSrvCacheTtl` fields in `config.json

@ -2,7 +2,7 @@ const Database = require('./database')
const MojangUpdater = require('./mojang') const MojangUpdater = require('./mojang')
const PingController = require('./ping') const PingController = require('./ping')
const Server = require('./server') const Server = require('./server')
const TimeTracker = require('./time') const { TimeTracker } = require('./time')
const MessageOf = require('./message') const MessageOf = require('./message')
const config = require('../config') const config = require('../config')
@ -42,17 +42,14 @@ class App {
client.on('message', (message) => { client.on('message', (message) => {
if (message === 'requestHistoryGraph') { if (message === 'requestHistoryGraph') {
// Send historical graphData built from all serverRegistrations // Send historical graphData built from all serverRegistrations
const graphData = {} const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData)
this.serverRegistrations.forEach((serverRegistration) => {
graphData[serverRegistration.serverId] = serverRegistration.graphData
})
// Send graphData in object wrapper to avoid needing to explicity filter // Send graphData in object wrapper to avoid needing to explicity filter
// any header data being appended by #MessageOf since the graph data is fed // any header data being appended by #MessageOf since the graph data is fed
// directly into the flot.js graphing system // directly into the graphing system
client.send(MessageOf('historyGraph', { client.send(MessageOf('historyGraph', {
graphData: graphData timestamps: this.timeTracker.getGraphPoints(),
graphData
})) }))
} }
}) })
@ -77,7 +74,7 @@ class App {
} }
})(), })(),
mojangServices: this.mojangUpdater.getLastUpdate(), mojangServices: this.mojangUpdater.getLastUpdate(),
timestampPoints: this.timeTracker.getPoints(), timestampPoints: this.timeTracker.getServerGraphPoints(),
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory())
} }

@ -1,6 +1,6 @@
const sqlite = require('sqlite3') const sqlite = require('sqlite3')
const TimeTracker = require('./time') const { TimeTracker } = require('./time')
class Database { class Database {
constructor (app) { constructor (app) {
@ -22,33 +22,47 @@ class Database {
const startTime = endTime - graphDuration const startTime = endTime - graphDuration
this.getRecentPings(startTime, endTime, pingData => { this.getRecentPings(startTime, endTime, pingData => {
const graphPointsByIp = [] const relativeGraphData = []
for (const row of pingData) { for (const row of pingData) {
// Load into temporary array // Load into temporary array
// This will be culled prior to being pushed to the serverRegistration // This will be culled prior to being pushed to the serverRegistration
let graphPoints = graphPointsByIp[row.ip] let graphData = relativeGraphData[row.ip]
if (!graphPoints) { if (!graphData) {
graphPoints = graphPointsByIp[row.ip] = [] relativeGraphData[row.ip] = graphData = [[], []]
} }
graphPoints.push([row.timestamp, row.playerCount]) // DANGER!
// This will pull the timestamp from each row into memory
// This is built under the assumption that each round of pings shares the same timestamp
// This enables all timestamp arrays to have consistent point selection and graph correctly
graphData[0].push(row.timestamp)
graphData[1].push(row.playerCount)
} }
Object.keys(graphPointsByIp).forEach(ip => { Object.keys(relativeGraphData).forEach(ip => {
// Match IPs to serverRegistration object // Match IPs to serverRegistration object
for (const serverRegistration of this._app.serverRegistrations) { for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.data.ip === ip) { if (serverRegistration.data.ip === ip) {
const graphPoints = graphPointsByIp[ip] const graphData = relativeGraphData[ip]
// Push the data into the instance and cull if needed // Push the data into the instance and cull if needed
serverRegistration.loadGraphPoints(graphPoints) serverRegistration.loadGraphPoints(startTime, graphData[0], graphData[1])
break break
} }
} }
}) })
// Since all timestamps are shared, use the array from the first ServerRegistration
// This is very dangerous and can break if data is out of sync
if (Object.keys(relativeGraphData).length > 0) {
const serverIp = Object.keys(relativeGraphData)[0]
const timestamps = relativeGraphData[serverIp][0]
this._app.timeTracker.loadGraphPoints(startTime, timestamps)
}
callback() callback()
}) })
} }
@ -63,10 +77,12 @@ class Database {
// Query recordData // Query recordData
// When complete increment completeTasks to know when complete // When complete increment completeTasks to know when complete
this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => { this.getRecord(serverRegistration.data.ip, (hasRecord, playerCount, timestamp) => {
serverRegistration.recordData = { if (hasRecord) {
playerCount: playerCount, serverRegistration.recordData = {
timestamp: timestamp playerCount,
timestamp: TimeTracker.toSeconds(timestamp)
}
} }
// Check if completedTasks hit the finish value // Check if completedTasks hit the finish value
@ -88,12 +104,26 @@ class Database {
getRecord (ip, callback) { getRecord (ip, callback) {
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [ this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [
ip ip
], (_, data) => callback(data[0]['MAX(playerCount)'], data[0].timestamp)) ], (_, data) => {
// For empty results, data will be length 1 with [null, null]
const playerCount = data[0]['MAX(playerCount)']
const timestamp = data[0].timestamp
// Allow null timestamps, the frontend will safely handle them
// This allows insertion of free standing records without a known timestamp
if (playerCount !== null) {
// eslint-disable-next-line standard/no-callback-literal
callback(true, playerCount, timestamp)
} else {
// eslint-disable-next-line standard/no-callback-literal
callback(false)
}
})
} }
insertPing (ip, timestamp, playerCount) { insertPing (ip, timestamp, unsafePlayerCount) {
const statement = this._sql.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)') const statement = this._sql.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)')
statement.run(timestamp, ip, playerCount) statement.run(timestamp, ip, unsafePlayerCount)
statement.finalize() statement.finalize()
} }
} }

@ -1,7 +1,8 @@
const dns = require('dns') const dns = require('dns')
const logger = require('./logger') const logger = require('./logger')
const TimeTracker = require('./time')
const { TimeTracker } = require('./time')
const config = require('../config') const config = require('../config')

@ -3,6 +3,9 @@ const minecraftBedrockPing = require('mcpe-ping-fixed')
const logger = require('./logger') const logger = require('./logger')
const MessageOf = require('./message') const MessageOf = require('./message')
const { TimeTracker } = require('./time')
const { getPlayerCountOrNull } = require('./util')
const config = require('../config') const config = require('../config')
@ -83,7 +86,7 @@ class PingController {
} }
pingAll = () => { pingAll = () => {
const timestamp = this._app.timeTracker.newTimestamp() const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp()
this.startPingTasks(results => { this.startPingTasks(results => {
const updates = [] const updates = []
@ -92,16 +95,17 @@ class PingController {
const result = results[serverRegistration.serverId] const result = results[serverRegistration.serverId]
// Log to database if enabled // Log to database if enabled
// Use null to represent a failed ping
if (config.logToDatabase) { if (config.logToDatabase) {
const playerCount = result.resp ? result.resp.players.online : 0 const unsafePlayerCount = getPlayerCountOrNull(result.resp)
this._app.database.insertPing(serverRegistration.data.ip, timestamp, playerCount) this._app.database.insertPing(serverRegistration.data.ip, timestamp, unsafePlayerCount)
} }
// Generate a combined update payload // Generate a combined update payload
// This includes any modified fields and flags used by the frontend // This includes any modified fields and flags used by the frontend
// This will not be cached and can contain live metadata // This will not be cached and can contain live metadata
const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version) const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version, updateHistoryGraph)
updates[serverRegistration.serverId] = update updates[serverRegistration.serverId] = update
} }
@ -109,7 +113,8 @@ class PingController {
// Send object since updates uses serverIds as keys // Send object since updates uses serverIds as keys
// Send a single timestamp entry since it is shared // Send a single timestamp entry since it is shared
this._app.server.broadcast(MessageOf('updateServers', { this._app.server.broadcast(MessageOf('updateServers', {
timestamp, timestamp: TimeTracker.toSeconds(timestamp),
updateHistoryGraph,
updates updates
})) }))
}) })

@ -1,9 +1,11 @@
const crypto = require('crypto') const crypto = require('crypto')
const DNSResolver = require('./dns') const DNSResolver = require('./dns')
const TimeTracker = require('./time')
const Server = require('./server') const Server = require('./server')
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time')
const { getPlayerCountOrNull } = require('./util')
const config = require('../config') const config = require('../config')
const minecraftVersions = require('../minecraft_versions') const minecraftVersions = require('../minecraft_versions')
@ -14,40 +16,33 @@ class ServerRegistration {
recordData recordData
graphData = [] graphData = []
constructor (serverId, data) { constructor (app, serverId, data) {
this._app = app
this.serverId = serverId this.serverId = serverId
this.data = data this.data = data
this._pingHistory = [] this._pingHistory = []
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port) this.dnsResolver = new DNSResolver(this.data.ip, this.data.port)
} }
handlePing (timestamp, resp, err, version) { handlePing (timestamp, resp, err, version, updateHistoryGraph) {
const playerCount = resp ? resp.players.online : 0 // Use null to represent a failed ping
const unsafePlayerCount = getPlayerCountOrNull(resp)
// Store into in-memory ping data // Store into in-memory ping data
this._pingHistory.push(playerCount) TimeTracker.pushAndShift(this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength())
// Trim pingHistory to avoid memory leaks
if (this._pingHistory.length > TimeTracker.getMaxServerGraphDataLength()) {
this._pingHistory.shift()
}
// Only notify the frontend to append to the historical graph // Only notify the frontend to append to the historical graph
// if both the graphing behavior is enabled and the backend agrees // if both the graphing behavior is enabled and the backend agrees
// that the ping is eligible for addition // that the ping is eligible for addition
let updateHistoryGraph = false if (updateHistoryGraph) {
TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength())
if (config.logToDatabase) {
if (this.addGraphPoint(resp !== undefined, playerCount, timestamp)) {
updateHistoryGraph = true
}
} }
// Delegate out update payload generation // Delegate out update payload generation
return this.getUpdate(timestamp, resp, err, version, updateHistoryGraph) return this.getUpdate(timestamp, resp, err, version)
} }
getUpdate (timestamp, resp, err, version, updateHistoryGraph) { getUpdate (timestamp, resp, err, version) {
const update = {} const update = {}
if (resp) { if (resp) {
@ -59,7 +54,7 @@ class ServerRegistration {
if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) { if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) {
this.recordData = { this.recordData = {
playerCount: resp.players.online, playerCount: resp.players.online,
timestamp: timestamp timestamp: TimeTracker.toSeconds(timestamp)
} }
// Append an updated recordData // Append an updated recordData
@ -80,12 +75,6 @@ class ServerRegistration {
if (this.findNewGraphPeak()) { if (this.findNewGraphPeak()) {
update.graphPeakData = this.getGraphPeak() update.graphPeakData = this.getGraphPeak()
} }
// Handled inside logToDatabase to validate logic from #getUpdate call
// Only append when true since an undefined value == false
if (updateHistoryGraph) {
update.updateHistoryGraph = true
}
} }
} else if (err) { } else if (err) {
// Append a filtered copy of err // Append a filtered copy of err
@ -135,59 +124,15 @@ class ServerRegistration {
} }
} }
loadGraphPoints (points) { loadGraphPoints (startTime, timestamps, points) {
// Filter pings so each result is a minute apart this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i])
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 #getMaxGraphDataLength
if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) {
this.graphData.shift()
}
return true
} }
findNewGraphPeak () { findNewGraphPeak () {
let index = -1 let index = -1
for (let i = 0; i < this.graphData.length; i++) { for (let i = 0; i < this.graphData.length; i++) {
const point = this.graphData[i] const point = this.graphData[i]
if (index === -1 || point[1] > this.graphData[index][1]) { if (point !== null && (index === -1 || point > this.graphData[index])) {
index = i index = i
} }
} }
@ -205,10 +150,9 @@ class ServerRegistration {
if (this._graphPeakIndex === undefined) { if (this._graphPeakIndex === undefined) {
return return
} }
const graphPeak = this.graphData[this._graphPeakIndex]
return { return {
playerCount: graphPeak[1], playerCount: this.graphData[this._graphPeakIndex],
timestamp: graphPeak[0] timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex)
} }
} }

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

11
lib/util.js Normal file

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

@ -22,7 +22,7 @@ servers.forEach((server, serverId) => {
} }
// Init a ServerRegistration instance of each entry in servers.json // Init a ServerRegistration instance of each entry in servers.json
app.serverRegistrations.push(new ServerRegistration(serverId, server)) app.serverRegistrations.push(new ServerRegistration(app, serverId, server))
}) })
if (!config.serverGraphDuration) { if (!config.serverGraphDuration) {

7
package-lock.json generated

@ -1,6 +1,6 @@
{ {
"name": "minetrack", "name": "minetrack",
"version": "5.4.3", "version": "5.5.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -8519,6 +8519,11 @@
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
"dev": true "dev": true
}, },
"uplot": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.0.8.tgz",
"integrity": "sha512-oS0YVdq6iEU4B+BXSX1Ln3Dd8iVHk9vKL9elWlIEa7cYzlhqDmnnJQsXSaLjYWTQbnDLRJuuaO3oyGF2q7loiw=="
},
"uri-js": { "uri-js": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",

@ -1,6 +1,6 @@
{ {
"name": "minetrack", "name": "minetrack",
"version": "5.4.3", "version": "5.5.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": "main.js", "main": "main.js",
"dependencies": { "dependencies": {
@ -10,6 +10,7 @@
"request": "2.88.2", "request": "2.88.2",
"serve-static": "^1.14.1", "serve-static": "^1.14.1",
"sqlite3": "4.1.1", "sqlite3": "4.1.1",
"uplot": "^1.0.8",
"winston": "^2.0.0", "winston": "^2.0.0",
"ws": "^7.2.5" "ws": "^7.2.5"
}, },