diff --git a/.eslintrc.json b/.eslintrc.json index 0ec52d3..4af0191 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,8 +1,7 @@ { "env": { "browser": true, - "es6": true, - "jquery": true + "es6": true }, "extends": [ "standard" diff --git a/assets/css/main.css b/assets/css/main.css index 9dcf5df..4ac7f61 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -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; +} \ No newline at end of file diff --git a/assets/html/index.html b/assets/html/index.html index 6ec540c..3be6101 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -10,6 +10,8 @@ + + Minetrack @@ -59,13 +61,6 @@ -
- On a mobile device? -

Minetrack has skipped automatically loading the historical graph to help save data and power.

-
- Load Historical Graph -
-
@@ -88,11 +83,6 @@ Powered by open source software - make it your own! - - - - - \ No newline at end of file diff --git a/assets/js/graph.js b/assets/js/graph.js index 9c88a11..b00880b 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -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 = ' ' + serverName + } + + if (typeof point === 'number') { + return serverName + ': ' + formatNumber(point) + } else { + return serverName + ': -' + } + }).join('
') + + text += '

' + formatTimestampSeconds(this._graphTimestamps[id]) + '' + + 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
' + formatTimestamp(item.datapoint[0]) - // Prefix text with the series label when possible - if (item.series && item.series.label) { - text = '' + item.series.label + '
' + 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') } } diff --git a/assets/js/main.js b/assets/js/main.js index c0ee1b5..cd7ee3e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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) diff --git a/assets/js/scale.js b/assets/js/scale.js new file mode 100644 index 0000000..ef1b9e0 --- /dev/null +++ b/assets/js/scale.js @@ -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] + } + } +} diff --git a/assets/js/servers.js b/assets/js/servers.js index bffe350..b79945e 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -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
' + 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) diff --git a/assets/js/socket.js b/assets/js/socket.js index 4fb764a..25cdd01 100644 --- a/assets/js/socket.js +++ b/assets/js/socket.js @@ -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 diff --git a/assets/js/sort.js b/assets/js/sort.js index ad6a25f..7c9209c 100644 --- a/assets/js/sort.js +++ b/assets/js/sort.js @@ -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)) diff --git a/assets/js/tooltip.js b/assets/js/tooltip.js new file mode 100644 index 0000000..0595d7d --- /dev/null +++ b/assets/js/tooltip.js @@ -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) + } + } + } + } +} diff --git a/assets/js/util.js b/assets/js/util.js index 6e55e42..474cc76 100644 --- a/assets/js/util.js +++ b/assets/js/util.js @@ -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 -} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d079175..a3db297 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/lib/app.js b/lib/app.js index 82c2b0a..c4419b3 100644 --- a/lib/app.js +++ b/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()) } diff --git a/lib/database.js b/lib/database.js index 16e5190..ee7481a 100644 --- a/lib/database.js +++ b/lib/database.js @@ -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() } } diff --git a/lib/dns.js b/lib/dns.js index 0eff328..a063cae 100644 --- a/lib/dns.js +++ b/lib/dns.js @@ -1,7 +1,8 @@ const dns = require('dns') const logger = require('./logger') -const TimeTracker = require('./time') + +const { TimeTracker } = require('./time') const config = require('../config') diff --git a/lib/ping.js b/lib/ping.js index 11a69b2..27e9dab 100644 --- a/lib/ping.js +++ b/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 })) }) diff --git a/lib/servers.js b/lib/servers.js index d6231a6..4568324 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -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) } } diff --git a/lib/time.js b/lib/time.js index 57386e2..8b3188c 100644 --- a/lib/time.js +++ b/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 +} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..d603481 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,11 @@ +function getPlayerCountOrNull (resp) { + if (resp) { + return resp.players.online + } else { + return null + } +} + +module.exports = { + getPlayerCountOrNull +} diff --git a/main.js b/main.js index 968df18..3056564 100644 --- a/main.js +++ b/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) { diff --git a/package-lock.json b/package-lock.json index 7b38dc0..93a463a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4fb1d4b..6a0dabb 100644 --- a/package.json +++ b/package.json @@ -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" },