diff --git a/assets/js/app.js b/assets/js/app.js
index 061da70..c291dc6 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,4 +1,5 @@
import { ServerRegistry } from './servers'
+import { SocketManager } from './socket'
import { SortController } from './sort'
import { GraphDisplayManager } from './graph'
import { MojangUpdater } from './mojang'
@@ -13,6 +14,7 @@ export class App {
this.tooltip = new Tooltip()
this.caption = new Caption()
this.serverRegistry = new ServerRegistry(this)
+ this.socketManager = new SocketManager(this)
this.sortController = new SortController(this)
this.graphDisplayManager = new GraphDisplayManager(this)
this.mojangUpdater = new MojangUpdater()
@@ -22,6 +24,11 @@ export class App {
this._taskIds = []
}
+ // Called once the DOM is ready and the app can begin setup
+ init () {
+ this.socketManager.createWebSocket()
+ }
+
setPageReady (isReady) {
document.getElementById('push').style.display = isReady ? 'block' : 'none'
document.getElementById('footer').style.display = isReady ? 'block' : 'none'
@@ -60,6 +67,7 @@ export class App {
// Reset individual tracker elements to flush any held data
this.serverRegistry.reset()
+ this.socketManager.reset()
this.sortController.reset()
this.graphDisplayManager.reset()
this.mojangUpdater.reset()
diff --git a/assets/js/main.js b/assets/js/main.js
index 70e6b17..c0ee1b5 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -2,141 +2,8 @@ import { App } from './app'
const app = new App()
-document.addEventListener('DOMContentLoaded', function () {
- const webSocket = new WebSocket('ws://' + location.host)
-
- // The backend will automatically push data once connected
- webSocket.onopen = () => {
- app.caption.set('Loading...')
- }
-
- webSocket.onclose = (event) => {
- app.handleDisconnect()
-
- // Modify page state to display loading overlay
- // Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection
- // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
- // Treat other codes as active errors (besides connectivity errors) when displaying the message
- if (event.code === 1006) {
- app.caption.set('Lost connection!')
- } else {
- app.caption.set('Disconnected due to error.')
- }
-
- // Reset modified DOM structures
- document.getElementById('big-graph-mobile-load-request').style.display = 'none'
-
- // TODO: Reconnect behavior
- }
-
- webSocket.onmessage = (message) => {
- const payload = JSON.parse(message.data)
-
- switch (payload.message) {
- case 'init':
- app.setPublicConfig(payload.config)
-
- // 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
- 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 (app.publicConfig.isGraphVisible) {
- if (app.graphDisplayManager.isVisible) {
- // Send request as a plain text string to avoid the server needing to parse JSON
- // This is mostly to simplify the backend server's need for error handling
- webSocket.send('requestHistoryGraph')
- } else {
- document.getElementById('big-graph-mobile-load-request').style.display = 'block'
- }
- }
-
- payload.servers.forEach(app.addServer)
-
- if (payload.mojangServices) {
- app.mojangUpdater.updateStatus(payload.mojangServices)
- }
-
- // Init payload contains all data needed to render the page
- // Alert the app it is ready
- app.handleSyncComplete()
-
- break
-
- case 'updateServer': {
- // The backend may send "update" events prior to receiving all "add" events
- // A server has only been added once it's ServerRegistration is defined
- // Checking undefined protects from this race condition
- const serverRegistration = app.serverRegistry.getServerRegistration(payload.serverId)
-
- if (serverRegistration) {
- serverRegistration.updateServerStatus(payload, false, app.publicConfig.minecraftVersions)
- }
-
- // Use update payloads to conditionally append data to graph
- // Skip any incoming updates if the graph is disabled
- if (payload.updateHistoryGraph && app.graphDisplayManager.isVisible) {
- // Update may not be successful, safely append 0 points
- const playerCount = payload.result ? payload.result.players.online : 0
-
- app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount)
-
- // Only redraw the graph if not mutating hidden data
- if (serverRegistration.isVisible) {
- app.graphDisplayManager.requestRedraw()
- }
- }
- break
- }
-
- case 'updateMojangServices': {
- app.mojangUpdater.updateStatus(payload)
- break
- }
-
- case 'historyGraph': {
- // Consider the graph visible since a payload has been received
- // This is used for the manual graph load request behavior
- app.graphDisplayManager.isVisible = true
-
- app.graphDisplayManager.buildPlotInstance(payload.graphData)
-
- // Build checkbox elements for graph controls
- let lastRowCounter = 0
- let controlsHTML = ''
-
- app.serverRegistry.getServerRegistrations()
- .map(serverRegistration => serverRegistration.data.name)
- .sort()
- .forEach(serverName => {
- const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
-
- controlsHTML += '
' +
- '' +
- ' ' + serverName +
- '
'
-
- // Occasionally break table rows using a magic number
- if (++lastRowCounter % 6 === 0) {
- controlsHTML += '
'
- }
- })
-
- // Apply generated HTML and show controls
- document.getElementById('big-graph-checkboxes').innerHTML = '
' +
- controlsHTML +
- '
'
-
- document.getElementById('big-graph-controls').style.display = 'block'
-
- // Bind click event for updating graph data
- app.graphDisplayManager.initEventListeners()
- break
- }
- }
- }
+document.addEventListener('DOMContentLoaded', () => {
+ app.init()
window.addEventListener('resize', function () {
app.percentageBar.redraw()
@@ -147,9 +14,7 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () {
// Send a graph data request to the backend
- // Send request as a plain text string to avoid the server needing to parse JSON
- // This is mostly to simplify the backend server's need for error handling
- webSocket.send('requestHistoryGraph')
+ app.socketManager.sendHistoryGraphRequest()
// Hide the activation link to avoid multiple requests
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
diff --git a/assets/js/socket.js b/assets/js/socket.js
new file mode 100644
index 0000000..fd0d6de
--- /dev/null
+++ b/assets/js/socket.js
@@ -0,0 +1,191 @@
+export class SocketManager {
+ constructor (app) {
+ this._app = app
+ this._hasRequestedHistoryGraph = false
+ this._reconnectDelayBase = 0
+ }
+
+ reset () {
+ this._hasRequestedHistoryGraph = false
+ }
+
+ createWebSocket () {
+ this._webSocket = new WebSocket('ws://' + location.host)
+
+ // The backend will automatically push data once connected
+ this._webSocket.onopen = () => {
+ this._app.caption.set('Loading...')
+
+ // Reset reconnection scheduling since the WebSocket has been established
+ this._reconnectDelayBase = 0
+ }
+
+ this._webSocket.onclose = (event) => {
+ this._app.handleDisconnect()
+
+ // Modify page state to display loading overlay
+ // Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection
+ // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
+ // Treat other codes as active errors (besides connectivity errors) when displaying the message
+ if (event.code === 1006) {
+ this._app.caption.set('Lost connection!')
+ } else {
+ 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()
+ }
+
+ this._webSocket.onmessage = (message) => {
+ const payload = JSON.parse(message.data)
+
+ switch (payload.message) {
+ case 'init':
+ this._app.setPublicConfig(payload.config)
+
+ // 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'
+ }
+ }
+
+ payload.servers.forEach(this._app.addServer)
+
+ if (payload.mojangServices) {
+ this._app.mojangUpdater.updateStatus(payload.mojangServices)
+ }
+
+ // Init payload contains all data needed to render the page
+ // Alert the app it is ready
+ this._app.handleSyncComplete()
+
+ break
+
+ case 'updateServer': {
+ // The backend may send "update" events prior to receiving all "add" events
+ // A server has only been added once it's ServerRegistration is defined
+ // Checking undefined protects from this race condition
+ const serverRegistration = this._app.serverRegistry.getServerRegistration(payload.serverId)
+
+ if (serverRegistration) {
+ serverRegistration.updateServerStatus(payload, false, this._app.publicConfig.minecraftVersions)
+ }
+
+ // Use update payloads to conditionally append data to graph
+ // Skip any incoming updates if the graph is disabled
+ if (payload.updateHistoryGraph && this._app.graphDisplayManager.isVisible) {
+ // Update may not be successful, safely append 0 points
+ const playerCount = payload.result ? payload.result.players.online : 0
+
+ this._app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount)
+
+ // Only redraw the graph if not mutating hidden data
+ if (serverRegistration.isVisible) {
+ this._app.graphDisplayManager.requestRedraw()
+ }
+ }
+ break
+ }
+
+ case 'updateMojangServices': {
+ this._app.mojangUpdater.updateStatus(payload)
+ break
+ }
+
+ 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)
+
+ // Build checkbox elements for graph controls
+ let lastRowCounter = 0
+ let controlsHTML = ''
+
+ this._app.serverRegistry.getServerRegistrations()
+ .map(serverRegistration => serverRegistration.data.name)
+ .sort()
+ .forEach(serverName => {
+ const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName)
+
+ controlsHTML += '
' +
+ '' +
+ ' ' + serverName +
+ '
'
+
+ // Occasionally break table rows using a magic number
+ if (++lastRowCounter % 6 === 0) {
+ controlsHTML += '
'
+ }
+ })
+
+ // Apply generated HTML and show controls
+ document.getElementById('big-graph-checkboxes').innerHTML = '
' +
+ controlsHTML +
+ '
'
+
+ document.getElementById('big-graph-controls').style.display = 'block'
+
+ // Bind click event for updating graph data
+ this._app.graphDisplayManager.initEventListeners()
+ break
+ }
+ }
+ }
+ }
+
+ scheduleReconnect () {
+ // Release any active WebSocket references
+ this._webSocket = undefined
+
+ this._reconnectDelayBase++
+
+ // Exponential backoff for reconnection attempts
+ // Clamp ceiling value to 30 seconds
+ this._reconnectDelaySeconds = Math.min((this._reconnectDelayBase * this._reconnectDelayBase), 30)
+
+ const reconnectInterval = setInterval(() => {
+ this._reconnectDelaySeconds--
+
+ if (this._reconnectDelaySeconds === 0) {
+ // Explicitly clear interval, this avoids race conditions
+ // #clearInterval first to avoid potential errors causing pre-mature returns
+ clearInterval(reconnectInterval)
+
+ // Update displayed text
+ this._app.caption.set('Reconnecting...')
+
+ // Attempt reconnection
+ // Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started
+ this.createWebSocket()
+ } else if (this._reconnectDelaySeconds > 0) {
+ // Update displayed text
+ this._app.caption.set('Reconnecting in ' + this._reconnectDelaySeconds + 's...')
+ }
+ }, 1000)
+ }
+
+ sendHistoryGraphRequest () {
+ if (!this._hasRequestedHistoryGraph) {
+ this._hasRequestedHistoryGraph = true
+
+ // Send request as a plain text string to avoid the server needing to parse JSON
+ // This is mostly to simplify the backend server's need for error handling
+ this._webSocket.send('requestHistoryGraph')
+ }
+ }
+}