commit
00347ed0a9
@ -1,8 +1,7 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"jquery": true
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"standard"
|
||||
|
@ -1,4 +1,7 @@
|
||||
@import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300);
|
||||
|
||||
@import url(uplot/dist/uPlot.min.css);
|
||||
|
||||
@import url(../css/icons.css);
|
||||
|
||||
* {
|
||||
@ -263,24 +266,6 @@ footer a:hover {
|
||||
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 {
|
||||
display: none;
|
||||
color: #e74c3c;
|
||||
@ -298,11 +283,27 @@ footer a:hover {
|
||||
}
|
||||
|
||||
.server .column-graph {
|
||||
float: right;
|
||||
height: 100px;
|
||||
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 */
|
||||
@ -331,19 +332,8 @@ footer a:hover {
|
||||
}
|
||||
|
||||
/* Historical graph */
|
||||
#big-graph-mobile-load-request {
|
||||
background: var(--background-color);
|
||||
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 {
|
||||
padding-right: 65px;
|
||||
}
|
||||
|
||||
#big-graph, #big-graph-controls, #big-graph-checkboxes {
|
||||
@ -461,3 +451,9 @@ footer a:hover {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* uPlot.css overrides */
|
||||
.uplot .select {
|
||||
background: var(--color-blue);
|
||||
opacity: 0.3;
|
||||
}
|
@ -10,6 +10,8 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<script defer src="../js/main.js"></script>
|
||||
|
||||
<title>Minetrack</title>
|
||||
|
||||
</head>
|
||||
@ -59,13 +61,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="big-graph-mobile-load-request">
|
||||
<strong>On a mobile device?</strong>
|
||||
<p>Minetrack has skipped automatically loading the historical graph to help save data and power.</p>
|
||||
<br>
|
||||
<a id="big-graph-mobile-load-request-button" class="button">Load Historical Graph</a>
|
||||
</div>
|
||||
|
||||
<div id="big-graph"></div>
|
||||
|
||||
<div id="big-graph-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>
|
||||
</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>
|
||||
|
||||
</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'
|
||||
|
||||
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 SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites'
|
||||
|
||||
export class GraphDisplayManager {
|
||||
// Only emit graph data request if not on mobile due to graph data size
|
||||
isVisible = !isMobileBrowser()
|
||||
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._graphData = []
|
||||
this._graphTimestamps = []
|
||||
this._hasLoadedSettings = false
|
||||
this._initEventListenersOnce = false
|
||||
this._showOnlyFavorites = false
|
||||
}
|
||||
|
||||
addGraphPoint (serverId, timestamp, playerCount) {
|
||||
addGraphPoint (timestamp, playerCounts) {
|
||||
if (!this._hasLoadedSettings) {
|
||||
// _hasLoadedSettings is controlled by #setGraphData
|
||||
// It will only be true once the context has been loaded and initial payload received
|
||||
@ -57,15 +29,31 @@ export class GraphDisplayManager {
|
||||
return
|
||||
}
|
||||
|
||||
const graphData = this._graphData[serverId]
|
||||
this._graphTimestamps.push(timestamp)
|
||||
|
||||
// Push the new data from the method call request
|
||||
graphData.push([timestamp, playerCount])
|
||||
|
||||
// Trim any outdated entries by filtering the array into a new array
|
||||
if (graphData.length > this._app.publicConfig.graphMaxLength) {
|
||||
graphData.shift()
|
||||
for (let i = 0; i < playerCounts.length; i++) {
|
||||
this._graphData[i].push(playerCounts[i])
|
||||
}
|
||||
|
||||
// Trim all data arrays to only the relevant portion
|
||||
// This keeps it in sync with backend data structures
|
||||
const graphMaxLength = this._app.publicConfig.graphMaxLength
|
||||
|
||||
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 () {
|
||||
@ -126,23 +114,27 @@ export class GraphDisplayManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Converts the backend data into the schema used by flot.js
|
||||
getVisibleGraphData () {
|
||||
return Object.keys(this._graphData)
|
||||
.map(Number)
|
||||
.map(serverId => this._app.serverRegistry.getServerRegistration(serverId))
|
||||
.filter(serverRegistration => serverRegistration !== undefined && serverRegistration.isVisible)
|
||||
.map(serverRegistration => {
|
||||
return {
|
||||
data: this._graphData[serverRegistration.serverId],
|
||||
yaxis: 1,
|
||||
label: serverRegistration.data.name,
|
||||
color: serverRegistration.data.color
|
||||
}
|
||||
})
|
||||
return this._app.serverRegistry.getServerRegistrations()
|
||||
.filter(serverRegistration => serverRegistration.isVisible)
|
||||
.map(serverRegistration => this._graphData[serverRegistration.serverId])
|
||||
}
|
||||
|
||||
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
|
||||
if (!this._hasLoadedSettings) {
|
||||
this._hasLoadedSettings = true
|
||||
@ -150,12 +142,124 @@ export class GraphDisplayManager {
|
||||
this.loadLocalStorage()
|
||||
}
|
||||
|
||||
this._graphData = graphData
|
||||
this._graphTimestamps = timestamps
|
||||
this._graphData = data
|
||||
|
||||
// Explicitly define a height so flot.js can rescale the Y axis
|
||||
document.getElementById('big-graph').style.height = '400px'
|
||||
const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => {
|
||||
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
|
||||
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.updateLocalStorage()
|
||||
|
||||
// Fire calls to the provided graph instance
|
||||
// This allows flot.js to manage redrawing and creates a helper method to reduce code duplication
|
||||
this._plotInstance.setData(this.getVisibleGraphData())
|
||||
this._plotInstance.setupGrid()
|
||||
this._plotInstance.draw()
|
||||
// Copy application state into the series data used by uPlot
|
||||
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
|
||||
this._plotInstance.series[serverRegistration.serverId + 1].show = serverRegistration.isVisible
|
||||
}
|
||||
|
||||
this._plotInstance.redraw()
|
||||
}
|
||||
|
||||
requestResize () {
|
||||
@ -189,11 +294,7 @@ export class GraphDisplayManager {
|
||||
}
|
||||
|
||||
resize = () => {
|
||||
if (this._plotInstance) {
|
||||
this._plotInstance.resize()
|
||||
this._plotInstance.setupGrid()
|
||||
this._plotInstance.draw()
|
||||
}
|
||||
this._plotInstance.setSize(this.getPlotSize())
|
||||
|
||||
// undefine value so #clearTimeout is not called
|
||||
// This is safe even if #resize is manually called since it removes the pending work
|
||||
@ -204,21 +305,6 @@ export class GraphDisplayManager {
|
||||
this._resizeRequestTimeout = undefined
|
||||
}
|
||||
|
||||
// Called by flot.js when they hover over a data point.
|
||||
handlePlotHover = (event, pos, item) => {
|
||||
if (!item) {
|
||||
this._app.tooltip.hide()
|
||||
} else {
|
||||
let text = formatNumber(item.datapoint[1]) + ' Players<br>' + formatTimestamp(item.datapoint[0])
|
||||
// Prefix text with the series label when possible
|
||||
if (item.series && item.series.label) {
|
||||
text = '<strong>' + item.series.label + '</strong><br>' + text
|
||||
}
|
||||
|
||||
this._app.tooltip.set(item.pageX, item.pageY, 10, 10, text)
|
||||
}
|
||||
}
|
||||
|
||||
initEventListeners () {
|
||||
if (!this._initEventListenersOnce) {
|
||||
this._initEventListenersOnce = true
|
||||
@ -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
|
||||
document.querySelectorAll('.graph-control').forEach((element) => {
|
||||
element.addEventListener('click', this.handleServerButtonClick, false)
|
||||
@ -317,8 +401,15 @@ export class GraphDisplayManager {
|
||||
}
|
||||
|
||||
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._plotInstance = undefined
|
||||
this._hasLoadedSettings = false
|
||||
|
||||
// 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('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
|
||||
app.graphDisplayManager.requestResize()
|
||||
}, 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)
|
||||
|
84
assets/js/scale.js
Normal file
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'
|
||||
|
||||
export const SERVER_GRAPH_OPTIONS = {
|
||||
series: {
|
||||
shadowSize: 0
|
||||
},
|
||||
xaxis: {
|
||||
font: {
|
||||
color: '#E3E3E3'
|
||||
},
|
||||
show: false
|
||||
},
|
||||
yaxis: {
|
||||
minTickSize: 100,
|
||||
tickDecimals: 0,
|
||||
show: true,
|
||||
tickLength: 10,
|
||||
tickFormatter: formatNumber,
|
||||
font: {
|
||||
color: '#E3E3E3'
|
||||
},
|
||||
labelWidth: -10
|
||||
},
|
||||
grid: {
|
||||
hoverable: true,
|
||||
color: '#696969'
|
||||
},
|
||||
colors: [
|
||||
'#E9E581'
|
||||
]
|
||||
}
|
||||
|
||||
export class ServerRegistry {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
@ -88,37 +63,107 @@ export class ServerRegistration {
|
||||
this._app = app
|
||||
this.serverId = serverId
|
||||
this.data = data
|
||||
this._graphData = []
|
||||
this._graphData = [[], []]
|
||||
this._failedSequentialPings = 0
|
||||
}
|
||||
|
||||
addGraphPoints (points, timestampPoints) {
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const point = points[i]
|
||||
const timestamp = timestampPoints[i]
|
||||
this._graphData.push([timestamp, point])
|
||||
}
|
||||
this._graphData = [
|
||||
timestampPoints.slice(),
|
||||
points
|
||||
]
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof payload.playerCount !== 'undefined') {
|
||||
if (typeof payload.playerCount === 'number') {
|
||||
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
|
||||
// doesn't instantly retrigger a layout change
|
||||
this._failedSequentialPings = 0
|
||||
@ -129,13 +174,20 @@ export class ServerRegistration {
|
||||
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
|
||||
this._plotInstance.setData([this._graphData])
|
||||
this._plotInstance.setupGrid()
|
||||
this._plotInstance.draw()
|
||||
this._plotInstance.setData(this._graphData)
|
||||
}
|
||||
|
||||
updateServerRankIndex (rankIndex) {
|
||||
@ -144,68 +196,68 @@ export class ServerRegistration {
|
||||
document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1)
|
||||
}
|
||||
|
||||
updateServerPeak (data) {
|
||||
const peakLabelElement = document.getElementById('peak_' + this.serverId)
|
||||
_renderValue (prefix, handler) {
|
||||
const labelElement = document.getElementById(prefix + '_' + this.serverId)
|
||||
|
||||
// Always set label once any peak data has been received
|
||||
peakLabelElement.style.display = 'block'
|
||||
labelElement.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)
|
||||
peakLabelElement.title = 'At ' + formatTimestamp(data.timestamp)
|
||||
if (targetElement) {
|
||||
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) {
|
||||
if (ping.versions) {
|
||||
const versionsElement = document.getElementById('version_' + this.serverId)
|
||||
|
||||
versionsElement.style.display = 'block'
|
||||
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || ''
|
||||
this._renderValue('version', formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '')
|
||||
}
|
||||
|
||||
if (ping.recordData) {
|
||||
// Always set label once any record data has been received
|
||||
const recordLabelElement = document.getElementById('record_' + this.serverId)
|
||||
this._renderValue('record', (element) => {
|
||||
if (ping.recordData.timestamp > 0) {
|
||||
element.innerText = formatNumber(ping.recordData.playerCount) + ' (' + formatDate(ping.recordData.timestamp) + ')'
|
||||
element.title = 'At ' + formatDate(ping.recordData.timestamp) + ' ' + formatTimestampSeconds(ping.recordData.timestamp)
|
||||
} else {
|
||||
element.innerText = formatNumber(ping.recordData.playerCount)
|
||||
}
|
||||
})
|
||||
|
||||
recordLabelElement.style.display = 'block'
|
||||
|
||||
const recordValueElement = document.getElementById('record-value_' + this.serverId)
|
||||
|
||||
const recordData = ping.recordData
|
||||
|
||||
// Safely handle legacy recordData that may not include the timestamp payload
|
||||
if (recordData.timestamp > 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
|
||||
this.lastRecordData = ping.recordData
|
||||
}
|
||||
|
||||
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) {
|
||||
// Hide any visible player-count and show the error element
|
||||
playerCountLabelElement.style.display = 'none'
|
||||
errorElement.style.display = 'block'
|
||||
this._hideValue('player-count')
|
||||
this._renderValue('error', ping.error.message)
|
||||
} else if (typeof ping.playerCount !== 'number') {
|
||||
this._hideValue('player-count')
|
||||
|
||||
errorElement.innerText = ping.error.message
|
||||
} else if (typeof ping.playerCount !== 'undefined') {
|
||||
// Ensure the player-count element is visible and hide the error element
|
||||
playerCountLabelElement.style.display = 'block'
|
||||
errorElement.style.display = 'none'
|
||||
|
||||
document.getElementById('player-count-value_' + this.serverId).innerText = formatNumber(ping.playerCount)
|
||||
// If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object
|
||||
// In this case playerCount will safely be null, so provide a generic error message instead
|
||||
this._renderValue('error', 'Failed to ping')
|
||||
} else if (typeof ping.playerCount === 'number') {
|
||||
this._hideValue('error')
|
||||
this._renderValue('player-count', formatNumber(ping.playerCount))
|
||||
}
|
||||
|
||||
// An updated favicon has been sent, update the src
|
||||
@ -259,8 +311,6 @@ export class ServerRegistration {
|
||||
}
|
||||
|
||||
initEventListeners () {
|
||||
$('#chart_' + this.serverId).bind('plothover', this._app.graphDisplayManager.handlePlotHover)
|
||||
|
||||
document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => {
|
||||
this._app.favoritesManager.handleFavoriteButtonClick(this)
|
||||
}, false)
|
||||
|
@ -38,9 +38,6 @@ export class SocketManager {
|
||||
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
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
@ -54,17 +51,12 @@ export class SocketManager {
|
||||
|
||||
// Display the main page component
|
||||
// Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn
|
||||
// Otherwise flot.js will cause visual alignment bugs
|
||||
this._app.setPageReady(true)
|
||||
|
||||
// Allow the graphDisplayManager to control whether or not the historical graph is loaded
|
||||
// Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload
|
||||
if (this._app.publicConfig.isGraphVisible) {
|
||||
if (this._app.graphDisplayManager.isVisible) {
|
||||
this.sendHistoryGraphRequest()
|
||||
} else {
|
||||
document.getElementById('big-graph-mobile-load-request').style.display = 'block'
|
||||
}
|
||||
this.sendHistoryGraphRequest()
|
||||
}
|
||||
|
||||
payload.servers.forEach((serverPayload, serverId) => {
|
||||
@ -82,8 +74,6 @@ export class SocketManager {
|
||||
break
|
||||
|
||||
case 'updateServers': {
|
||||
let requestGraphRedraw = false
|
||||
|
||||
for (let serverId = 0; serverId < payload.updates.length; serverId++) {
|
||||
// The backend may send "update" events prior to receiving all "add" events
|
||||
// A server has only been added once it's ServerRegistration is defined
|
||||
@ -93,27 +83,15 @@ export class SocketManager {
|
||||
|
||||
if (serverRegistration) {
|
||||
serverRegistration.handlePing(serverUpdate, payload.timestamp)
|
||||
|
||||
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
|
||||
if (requestGraphRedraw) {
|
||||
// Bulk add playerCounts into graph during #updateHistoryGraph
|
||||
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()
|
||||
}
|
||||
|
||||
@ -129,11 +107,7 @@ export class SocketManager {
|
||||
}
|
||||
|
||||
case 'historyGraph': {
|
||||
// Consider the graph visible since a payload has been received
|
||||
// This is used for the manual graph load request behavior
|
||||
this._app.graphDisplayManager.isVisible = true
|
||||
|
||||
this._app.graphDisplayManager.buildPlotInstance(payload.graphData)
|
||||
this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData)
|
||||
|
||||
// Build checkbox elements for graph controls
|
||||
let lastRowCounter = 0
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { isArrayEqual } from './util'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{
|
||||
getName: () => 'Players',
|
||||
@ -164,8 +162,21 @@ export class SortController {
|
||||
// This avoids DOM updates and graphs being redrawn
|
||||
const sortedServerIds = sortedServers.map(server => server.serverId)
|
||||
|
||||
if (isArrayEqual(sortedServerIds, this._lastSortedServers)) {
|
||||
return
|
||||
if (this._lastSortedServers) {
|
||||
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
|
||||
@ -176,7 +187,10 @@ export class SortController {
|
||||
|
||||
// Update the DOM structure
|
||||
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
|
||||
serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration))
|
||||
|
28
assets/js/tooltip.js
Normal file
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(', ')
|
||||
}
|
||||
|
||||
export function formatTimestamp (millis) {
|
||||
export function formatTimestampSeconds (secs) {
|
||||
const date = new Date(0)
|
||||
date.setUTCSeconds(millis / 1000)
|
||||
date.setUTCSeconds(secs)
|
||||
return date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
export function formatDate (millis) {
|
||||
export function formatDate (secs) {
|
||||
const date = new Date(0)
|
||||
date.setUTCSeconds(millis / 1000)
|
||||
date.setUTCSeconds(secs)
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
@ -119,26 +119,3 @@ export function formatPercent (x, over) {
|
||||
export function formatNumber (x) {
|
||||
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)*
|
||||
- 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
|
||||
|
15
lib/app.js
15
lib/app.js
@ -2,7 +2,7 @@ const Database = require('./database')
|
||||
const MojangUpdater = require('./mojang')
|
||||
const PingController = require('./ping')
|
||||
const Server = require('./server')
|
||||
const TimeTracker = require('./time')
|
||||
const { TimeTracker } = require('./time')
|
||||
const MessageOf = require('./message')
|
||||
|
||||
const config = require('../config')
|
||||
@ -42,17 +42,14 @@ class App {
|
||||
client.on('message', (message) => {
|
||||
if (message === 'requestHistoryGraph') {
|
||||
// Send historical graphData built from all serverRegistrations
|
||||
const graphData = {}
|
||||
|
||||
this.serverRegistrations.forEach((serverRegistration) => {
|
||||
graphData[serverRegistration.serverId] = serverRegistration.graphData
|
||||
})
|
||||
const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData)
|
||||
|
||||
// Send graphData in object wrapper to avoid needing to explicity filter
|
||||
// any header data being appended by #MessageOf since the graph data is fed
|
||||
// directly into the flot.js graphing system
|
||||
// directly into the graphing system
|
||||
client.send(MessageOf('historyGraph', {
|
||||
graphData: graphData
|
||||
timestamps: this.timeTracker.getGraphPoints(),
|
||||
graphData
|
||||
}))
|
||||
}
|
||||
})
|
||||
@ -77,7 +74,7 @@ class App {
|
||||
}
|
||||
})(),
|
||||
mojangServices: this.mojangUpdater.getLastUpdate(),
|
||||
timestampPoints: this.timeTracker.getPoints(),
|
||||
timestampPoints: this.timeTracker.getServerGraphPoints(),
|
||||
servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory())
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
const sqlite = require('sqlite3')
|
||||
|
||||
const TimeTracker = require('./time')
|
||||
const { TimeTracker } = require('./time')
|
||||
|
||||
class Database {
|
||||
constructor (app) {
|
||||
@ -22,33 +22,47 @@ class Database {
|
||||
const startTime = endTime - graphDuration
|
||||
|
||||
this.getRecentPings(startTime, endTime, pingData => {
|
||||
const graphPointsByIp = []
|
||||
const relativeGraphData = []
|
||||
|
||||
for (const row of pingData) {
|
||||
// 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] = []
|
||||
let graphData = relativeGraphData[row.ip]
|
||||
if (!graphData) {
|
||||
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
|
||||
for (const serverRegistration of this._app.serverRegistrations) {
|
||||
if (serverRegistration.data.ip === ip) {
|
||||
const graphPoints = graphPointsByIp[ip]
|
||||
const graphData = relativeGraphData[ip]
|
||||
|
||||
// Push the data into the instance and cull if needed
|
||||
serverRegistration.loadGraphPoints(graphPoints)
|
||||
serverRegistration.loadGraphPoints(startTime, graphData[0], graphData[1])
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
@ -63,10 +77,12 @@ class Database {
|
||||
|
||||
// Query recordData
|
||||
// When complete increment completeTasks to know when complete
|
||||
this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => {
|
||||
serverRegistration.recordData = {
|
||||
playerCount: playerCount,
|
||||
timestamp: timestamp
|
||||
this.getRecord(serverRegistration.data.ip, (hasRecord, playerCount, timestamp) => {
|
||||
if (hasRecord) {
|
||||
serverRegistration.recordData = {
|
||||
playerCount,
|
||||
timestamp: TimeTracker.toSeconds(timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if completedTasks hit the finish value
|
||||
@ -88,12 +104,26 @@ class Database {
|
||||
getRecord (ip, callback) {
|
||||
this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE 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 (?, ?, ?)')
|
||||
statement.run(timestamp, ip, playerCount)
|
||||
statement.run(timestamp, ip, unsafePlayerCount)
|
||||
statement.finalize()
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
const dns = require('dns')
|
||||
|
||||
const logger = require('./logger')
|
||||
const TimeTracker = require('./time')
|
||||
|
||||
const { TimeTracker } = require('./time')
|
||||
|
||||
const config = require('../config')
|
||||
|
||||
|
15
lib/ping.js
15
lib/ping.js
@ -3,6 +3,9 @@ const minecraftBedrockPing = require('mcpe-ping-fixed')
|
||||
|
||||
const logger = require('./logger')
|
||||
const MessageOf = require('./message')
|
||||
const { TimeTracker } = require('./time')
|
||||
|
||||
const { getPlayerCountOrNull } = require('./util')
|
||||
|
||||
const config = require('../config')
|
||||
|
||||
@ -83,7 +86,7 @@ class PingController {
|
||||
}
|
||||
|
||||
pingAll = () => {
|
||||
const timestamp = this._app.timeTracker.newTimestamp()
|
||||
const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp()
|
||||
|
||||
this.startPingTasks(results => {
|
||||
const updates = []
|
||||
@ -92,16 +95,17 @@ class PingController {
|
||||
const result = results[serverRegistration.serverId]
|
||||
|
||||
// Log to database if enabled
|
||||
// Use null to represent a failed ping
|
||||
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
|
||||
// This includes any modified fields and flags used by the frontend
|
||||
// This will not be cached and can contain live metadata
|
||||
const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version)
|
||||
const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version, updateHistoryGraph)
|
||||
|
||||
updates[serverRegistration.serverId] = update
|
||||
}
|
||||
@ -109,7 +113,8 @@ class PingController {
|
||||
// Send object since updates uses serverIds as keys
|
||||
// Send a single timestamp entry since it is shared
|
||||
this._app.server.broadcast(MessageOf('updateServers', {
|
||||
timestamp,
|
||||
timestamp: TimeTracker.toSeconds(timestamp),
|
||||
updateHistoryGraph,
|
||||
updates
|
||||
}))
|
||||
})
|
||||
|
@ -1,9 +1,11 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
const DNSResolver = require('./dns')
|
||||
const TimeTracker = require('./time')
|
||||
const Server = require('./server')
|
||||
|
||||
const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time')
|
||||
const { getPlayerCountOrNull } = require('./util')
|
||||
|
||||
const config = require('../config')
|
||||
const minecraftVersions = require('../minecraft_versions')
|
||||
|
||||
@ -14,40 +16,33 @@ class ServerRegistration {
|
||||
recordData
|
||||
graphData = []
|
||||
|
||||
constructor (serverId, data) {
|
||||
constructor (app, serverId, data) {
|
||||
this._app = app
|
||||
this.serverId = serverId
|
||||
this.data = data
|
||||
this._pingHistory = []
|
||||
this.dnsResolver = new DNSResolver(this.data.ip, this.data.port)
|
||||
}
|
||||
|
||||
handlePing (timestamp, resp, err, version) {
|
||||
const playerCount = resp ? resp.players.online : 0
|
||||
handlePing (timestamp, resp, err, version, updateHistoryGraph) {
|
||||
// Use null to represent a failed ping
|
||||
const unsafePlayerCount = getPlayerCountOrNull(resp)
|
||||
|
||||
// Store into in-memory ping data
|
||||
this._pingHistory.push(playerCount)
|
||||
|
||||
// Trim pingHistory to avoid memory leaks
|
||||
if (this._pingHistory.length > TimeTracker.getMaxServerGraphDataLength()) {
|
||||
this._pingHistory.shift()
|
||||
}
|
||||
TimeTracker.pushAndShift(this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength())
|
||||
|
||||
// Only notify the frontend to append to the historical graph
|
||||
// if both the graphing behavior is enabled and the backend agrees
|
||||
// that the ping is eligible for addition
|
||||
let updateHistoryGraph = false
|
||||
|
||||
if (config.logToDatabase) {
|
||||
if (this.addGraphPoint(resp !== undefined, playerCount, timestamp)) {
|
||||
updateHistoryGraph = true
|
||||
}
|
||||
if (updateHistoryGraph) {
|
||||
TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength())
|
||||
}
|
||||
|
||||
// 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 = {}
|
||||
|
||||
if (resp) {
|
||||
@ -59,7 +54,7 @@ class ServerRegistration {
|
||||
if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) {
|
||||
this.recordData = {
|
||||
playerCount: resp.players.online,
|
||||
timestamp: timestamp
|
||||
timestamp: TimeTracker.toSeconds(timestamp)
|
||||
}
|
||||
|
||||
// Append an updated recordData
|
||||
@ -80,12 +75,6 @@ class ServerRegistration {
|
||||
if (this.findNewGraphPeak()) {
|
||||
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) {
|
||||
// Append a filtered copy of err
|
||||
@ -135,59 +124,15 @@ class ServerRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
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 #getMaxGraphDataLength
|
||||
if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) {
|
||||
this.graphData.shift()
|
||||
}
|
||||
|
||||
return true
|
||||
loadGraphPoints (startTime, timestamps, points) {
|
||||
this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i])
|
||||
}
|
||||
|
||||
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]) {
|
||||
if (point !== null && (index === -1 || point > this.graphData[index])) {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
@ -205,10 +150,9 @@ class ServerRegistration {
|
||||
if (this._graphPeakIndex === undefined) {
|
||||
return
|
||||
}
|
||||
const graphPeak = this.graphData[this._graphPeakIndex]
|
||||
return {
|
||||
playerCount: graphPeak[1],
|
||||
timestamp: graphPeak[0]
|
||||
playerCount: this.graphData[this._graphPeakIndex],
|
||||
timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
78
lib/time.js
78
lib/time.js
@ -1,25 +1,56 @@
|
||||
const config = require('../config.json')
|
||||
|
||||
const GRAPH_UPDATE_TIME_GAP = 60 * 1000 // 60 seconds
|
||||
|
||||
class TimeTracker {
|
||||
constructor (app) {
|
||||
this._app = app
|
||||
this._points = []
|
||||
this._serverGraphPoints = []
|
||||
this._graphPoints = []
|
||||
}
|
||||
|
||||
newTimestamp () {
|
||||
newPointTimestamp () {
|
||||
const timestamp = TimeTracker.getEpochMillis()
|
||||
|
||||
this._points.push(timestamp)
|
||||
TimeTracker.pushAndShift(this._serverGraphPoints, timestamp, TimeTracker.getMaxServerGraphDataLength())
|
||||
|
||||
if (this._points.length > TimeTracker.getMaxServerGraphDataLength()) {
|
||||
this._points.shift()
|
||||
// Flag each group as history graph additions each minute
|
||||
// This is sent to the frontend for graph updates
|
||||
const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP)
|
||||
|
||||
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 () {
|
||||
return this._points
|
||||
loadGraphPoints (startTime, timestamps) {
|
||||
// This is a copy of ServerRegistration#loadGraphPoints
|
||||
// timestamps contains original timestamp data and needs to be filtered into minutes
|
||||
this._graphPoints = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => timestamps[i])
|
||||
}
|
||||
|
||||
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 () {
|
||||
@ -31,8 +62,35 @@ class TimeTracker {
|
||||
}
|
||||
|
||||
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
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
|
||||
}
|
2
main.js
2
main.js
@ -22,7 +22,7 @@ servers.forEach((server, serverId) => {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minetrack",
|
||||
"version": "5.4.3",
|
||||
"version": "5.5.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@ -8519,6 +8519,11 @@
|
||||
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
|
||||
"dev": true
|
||||
},
|
||||
"uplot": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.0.8.tgz",
|
||||
"integrity": "sha512-oS0YVdq6iEU4B+BXSX1Ln3Dd8iVHk9vKL9elWlIEa7cYzlhqDmnnJQsXSaLjYWTQbnDLRJuuaO3oyGF2q7loiw=="
|
||||
},
|
||||
"uri-js": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minetrack",
|
||||
"version": "5.4.3",
|
||||
"version": "5.5.0",
|
||||
"description": "A Minecraft server tracker that lets you focus on the basics.",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
@ -10,6 +10,7 @@
|
||||
"request": "2.88.2",
|
||||
"serve-static": "^1.14.1",
|
||||
"sqlite3": "4.1.1",
|
||||
"uplot": "^1.0.8",
|
||||
"winston": "^2.0.0",
|
||||
"ws": "^7.2.5"
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user