commit
00347ed0a9
@ -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 () {
|
||||||
this._graphData = []
|
// Destroy graphs and unload references
|
||||||
|
// uPlot#destroy handles listener de-registration, DOM reset, etc
|
||||||
|
if (this._plotInstance) {
|
||||||
|
this._plotInstance.destroy()
|
||||||
this._plotInstance = undefined
|
this._plotInstance = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
this._graphTimestamps = []
|
||||||
|
this._graphData = []
|
||||||
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
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 () {
|
|
||||||
// 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) + ')'
|
||||||
recordLabelElement.style.display = 'block'
|
element.title = 'At ' + formatDate(ping.recordData.timestamp) + ' ' + formatTimestampSeconds(ping.recordData.timestamp)
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
recordValueElement.innerText = formatNumber(recordData.playerCount)
|
element.innerText = formatNumber(ping.recordData.playerCount)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.lastRecordData = recordData
|
this.lastRecordData = ping.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Run redraw tasks after handling bulk updates
|
||||||
if (requestGraphRedraw) {
|
|
||||||
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,9 +162,22 @@ 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) {
|
||||||
|
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
|
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
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
|
||||||
|
15
lib/app.js
15
lib/app.js
@ -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) => {
|
||||||
|
if (hasRecord) {
|
||||||
serverRegistration.recordData = {
|
serverRegistration.recordData = {
|
||||||
playerCount: playerCount,
|
playerCount,
|
||||||
timestamp: timestamp
|
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')
|
||||||
|
|
||||||
|
15
lib/ping.js
15
lib/ping.js
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
78
lib/time.js
78
lib/time.js
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TimeTracker
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
static pushAndShift (array, value, maxLength) {
|
||||||
|
array.push(value)
|
||||||
|
|
||||||
|
if (array.length > maxLength) {
|
||||||
|
array.splice(0, array.length - maxLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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
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"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user