Minetrack 5 (#143)

* remove unused #getServer methods, inline #roundToPoint

* replace #safeName regex with incremental ids

* remove legacy #setInterval based #updateMojangServices handling

* add Tooltip class, move faviconSize to css instead of js

* move server id assignment to ServerRegistry

* move printPort logic to formatMinecraftServerAddress, add MINECRAFT_DEFAULT_PORTS

* simplify ping tracking

* rework perc-bar tooltip to not use mousemove event

* begin moving graphing logic to GraphDisplayManager

* begin merge graph point tracking into graphDisplayManager

* centralizing graphing logic into GraphDisplayManager

* properly reset GraphDisplayManager when handling disconnects

* move individual server graph data into ServerGraph class

* constantly run sortServers loop to simplify logic

* inline #updateMojangServices method

* resize performance improvements

* remove legacy bootTime refresh behavior, require manual user refresh

* move class defs to core.js

* remove unused #isGraphDataVisible arg

* remove #toggleControlsDrawer

* dont call #updatePercentageBar in #updateServerStatus calls

* centralize caption handling

* inline #msToTime

* remove hackish seconds handling for timestamps

* reduce #forEach calls with filter/map

* safely fallback to errorMessage if errno/description does not match

* Add /images/missing_favicon.png path instead of putting base64 in js

* remove debug

* cleanup mojang status handling

* move historyPlot instance into GraphDisplayManager

* cleanup checkbox html generation

* cleanup #updateServerStatus

* fix up tooltip styling

* move jquery code out of core.js

* fix add server race condition when initially pinging servers

* send error.placeholder=true for pending pings so the frontend can discard later

* filter placeholder pings sent by the backend

* del assets/images/logo_2014.png

* move graph code into graph.js

* merge pingTracker into ServerRegistry+ServerGraph

* remove todos

* simplify getVisibleGraphData

* fix potential sortServers race condition when adding

* use #show instead of #fadeIn(0)

* remove publicConfig.json, send over socket

* update docs/CHANGELOG.md

* getOrAssign -> getOrCreateId

* dont delete graph controls when disconnected

* early work cleaning up HTML+CSS structures

* cleanup server css elements

* cleanup graph control css elements

* move base CSS color values into @media(prefers-color-scheme: light)

* move CSS magic colors to vars

* reduce duplicated CSS color rules

* inline body text color CSS

* WIP replacing jQuery calls with vanilla JS

* WIP replacing jQuery calls with vanilla JS

* replace getElementsByClass with querySelectorAll

* typeMarker -> serverTypeHTML

* use jQuery slim for remaining flot.js dependency

* merge setAllGraphVisibility into GraphDisplayManager

* break apart element update and redraw logic

* add eslint + parcel bundler

* auto lint assets/js when building

* statically serve favicons/ for faviconOverrides outside of dist/

* only send favicons when changed

* move faviconOverride behavior into entry in servers.json

* add warning to backend server files

* remove .server-favicon-missing class

* add Minetrack 5 migration guide

* add npm run build step to install.sh

* adjust package.json version to 5.0.0

* remove js references from index.html

* move logic and behavior out of site.js

* cleanup ServerRegistry methods

* prevent multiple history graph redraws

* add comments

* cleanup #addServer usage, move to App

* move graph control bindings into GraphDisplayManager

* site.js -> main.js, core.js -> servers.js

* move Tooltip/Caption into util.js

* spacing tweak

* format index.html

* ensure the frontend does not handling updateHistoryGraph events

* prevent versions/record updates if the same value

* avoid empty percbar updates, ensure versions are sorted

* only include main.js ref in index.html

* serve minified copy of font awesome directly

* bundle icons.css into main.css, remove Open Sans 400

* add new SVG logo

* update docs/CHANGELOG.md

* new design, server version grouping

* remove start.sh call from install.sh

* move graph controls into header with new button

* move #handleSettingsToggle back to graph

* fix legacy code behavior of currentVersionIndex applying globally

* fix header text color in light mode

* fix mojang status text color in light mode

* fix toggle settings and checkbox colors

* tweak button hover color

* tweak button hover color

* add new status-overlay to avoid complicated DOM management during loading

* fix initial graph rendering bug

* add comments

* update default graph tick sizes

* prevent #tooltip from overflowing page

* remove localhost spec

* prevent minor connection errors from reshuffling layout

* update CHANGELOG.md

* add message/button for manually loading historical graph on mobile devices

* send isGraphVisible to frontend to prevent alert if logToDatabase: false

* send timestamp data with record

* update docs/CHANGELOG.md

* remove clock icon

* remove 24h peak timestamp

* Only check favicon if present

* safely handle undefined/empty knownVersions in #formatMinecraftVersions

* merge config.versions and minecraft.json into minecraft_versions.json, simplify index matching behavior

* remove localhost url in socket.io config

* stub methods/linkage for FocusManager

* add #isObjectEqual hack, add event proxying to FocusManager

* wip extended stats box

* remove server-type badging

* tweak mojang unstable color

* serve socket.io-client using parcel

* fix incorrect mojang status colors

* remove legacy capitalization design

* redesign focus boxes

* update docs/CHANGELOG.md

* remove localhost ref

* color clock icon

* use background-color for hover effect, remove unused var

* improve stats focus box icons

* change mojang sessions icon to globe

* Add favorites system

* remove focus boxes

* update docs/CHANGELOG.md

* remove focus icons from font

* simplify graph related event binding

* Add Sort By button

* store current sortOption in localStorage

* update docs/CHANGELOG.md

* move magic 0 sortOption to SORT_OPTION_INDEX_DEFAULT

* remove localhost ref

* merge #settings-toggle, #sort-by and .mojang-status CSS

* remove .focus-box CSS

* use sortedServerIds for _lastSortedServers

* tweak --color-blue

* new missing_favicon design to match logo

* edit footer CSS/text, remove github icon

* replace player count diff counter with GROWTH sort option

* italize non-default sort options

* add Only Favorites button to auto sync favorites to the visible graph data

* add icons to graph control buttons

* update docs/CHANGELOG.md

* use * to denote non-default sort option instead

* remove localhost url in socket.io config

* add value highlighting to make sort by easier to read

* remove last remaining uppercase text

* remove serverTypesVisible from config.json

* simplify header CSS, fix spacing with logToDatabase=false

* fix inverted text color on highlighted values

* remove localhost url in socket.io config

* break header into rows on mobile devices

Co-authored-by: Hugo Manrique <contact@hugmanrique.me>
This commit is contained in:
Nick Krecklow
2020-04-19 19:27:59 -05:00
committed by GitHub
parent 8c5e25b259
commit f875361bc7
41 changed files with 10304 additions and 1330 deletions

140
assets/js/app.js Normal file
View File

@ -0,0 +1,140 @@
import { ServerRegistry } from './servers'
import { SortController } from './sort'
import { GraphDisplayManager } from './graph'
import { MojangUpdater } from './mojang'
import { PercentageBar } from './percbar'
import { FavoritesManager } from './favorites'
import { Tooltip, Caption, formatNumber } from './util'
export class App {
publicConfig
constructor () {
this.tooltip = new Tooltip()
this.caption = new Caption()
this.serverRegistry = new ServerRegistry(this)
this.sortController = new SortController(this)
this.graphDisplayManager = new GraphDisplayManager(this)
this.mojangUpdater = new MojangUpdater()
this.percentageBar = new PercentageBar(this)
this.favoritesManager = new FavoritesManager(this)
this._taskIds = []
}
setPageReady (isReady) {
document.getElementById('push').style.display = isReady ? 'block' : 'none'
document.getElementById('footer').style.display = isReady ? 'block' : 'none'
document.getElementById('status-overlay').style.display = isReady ? 'none' : 'block'
}
setPublicConfig (publicConfig) {
this.publicConfig = publicConfig
this.serverRegistry.assignServers(publicConfig.servers)
// Start repeating frontend tasks once it has received enough data to be considered active
// This simplifies management logic at the cost of each task needing to safely handle empty data
this.initTasks()
}
handleSyncComplete () {
this.caption.hide()
// Load favorites since all servers are registered
this.favoritesManager.loadLocalStorage()
// Run a single bulk server sort instead of per-add event since there may be multiple
this.sortController.show()
this.percentageBar.redraw()
}
initTasks () {
this._taskIds.push(setInterval(this.sortController.sortServers, 5000))
this._taskIds.push(setInterval(this.updateGlobalStats, 1000))
this._taskIds.push(setInterval(this.percentageBar.redraw, 1000))
}
handleDisconnect () {
this.tooltip.hide()
// Reset individual tracker elements to flush any held data
this.serverRegistry.reset()
this.sortController.reset()
this.graphDisplayManager.reset()
this.mojangUpdater.reset()
this.percentageBar.reset()
// Undefine publicConfig, resynced during the connection handshake
this.publicConfig = undefined
// Clear all task ids, if any
this._taskIds.forEach(clearInterval)
this._taskIds = []
// Reset hidden values created by #updateGlobalStats
this._lastTotalPlayerCount = undefined
this._lastServerRegistrationCount = undefined
// Reset modified DOM structures
document.getElementById('stat_totalPlayers').innerText = 0
document.getElementById('stat_networks').innerText = 0
// Modify page state to display loading overlay
this.caption.set('Lost connection!')
this.setPageReady(false)
}
getTotalPlayerCount () {
return this.serverRegistry.getServerRegistrations()
.map(serverRegistration => serverRegistration.playerCount)
.reduce((sum, current) => sum + current, 0)
}
addServer = (pings) => {
// Even if the backend has never pinged the server, the frontend is promised a placeholder object.
// result = undefined
// error = defined with "Waiting" description
// info = safely defined with configured data
const latestPing = pings[pings.length - 1]
const serverRegistration = this.serverRegistry.createServerRegistration(latestPing.info.name)
serverRegistration.initServerStatus(latestPing)
// Push the historical data into the graph
// This will trim and format the data so it is ready for the graph to render once init
serverRegistration.addGraphPoints(pings)
// Create the plot instance internally with the restructured and cleaned data
serverRegistration.buildPlotInstance()
// Handle the last known state (if any) as an incoming update
// This triggers the main update pipeline and enables centralized update handling
serverRegistration.updateServerStatus(latestPing, true, this.publicConfig.minecraftVersions)
// Allow the ServerRegistration to bind any DOM events with app instance context
serverRegistration.initEventListeners()
}
updateGlobalStats = () => {
// Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering
const totalPlayerCount = this.getTotalPlayerCount()
if (totalPlayerCount !== this._lastTotalPlayerCount) {
this._lastTotalPlayerCount = totalPlayerCount
document.getElementById('stat_totalPlayers').innerText = formatNumber(totalPlayerCount)
}
// Only redraw when needed
// These operations are relatively cheap, but the site already does too much rendering
const serverRegistrationCount = this.serverRegistry.getServerRegistrations().length
if (serverRegistrationCount !== this._lastServerRegistrationCount) {
this._lastServerRegistrationCount = serverRegistrationCount
document.getElementById('stat_networks').innerText = serverRegistrationCount
}
}
}

69
assets/js/favorites.js Normal file
View File

@ -0,0 +1,69 @@
export const FAVORITE_SERVERS_STORAGE_KEY = 'minetrack_favorite_servers'
export class FavoritesManager {
constructor (app) {
this._app = app
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
let serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
if (serverNames) {
serverNames = JSON.parse(serverNames)
for (let i = 0; i < serverNames.length; i++) {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverNames[i])
// The serverName may not exist in the backend configuration anymore
// Ensure serverRegistration is defined before mutating data or considering valid
if (serverRegistration) {
serverRegistration.isFavorite = true
// Update icon since by default it is unfavorited
document.getElementById('favorite-toggle_' + serverRegistration.serverId).setAttribute('class', this.getIconClass(serverRegistration.isFavorite))
}
}
}
}
}
updateLocalStorage () {
if (typeof localStorage !== 'undefined') {
// Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => serverRegistration.isFavorite)
.map(serverRegistration => serverRegistration.data.name)
if (serverNames.length > 0) {
// Only save if the array contains data, otherwise clear the item
localStorage.setItem(FAVORITE_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
} else {
localStorage.removeItem(FAVORITE_SERVERS_STORAGE_KEY)
}
}
}
handleFavoriteButtonClick = (serverRegistration) => {
serverRegistration.isFavorite = !serverRegistration.isFavorite
// Update the displayed favorite icon
document.getElementById('favorite-toggle_' + serverRegistration.serverId).setAttribute('class', this.getIconClass(serverRegistration.isFavorite))
// Request the app controller instantly re-sort the server listing
// This handles the favorite sorting logic internally
this._app.sortController.sortServers()
this._app.graphDisplayManager.handleServerIsFavoriteUpdate(serverRegistration)
// Write an updated settings payload
this.updateLocalStorage()
}
getIconClass (isFavorite) {
if (isFavorite) {
return 'icon-star server-is-favorite'
} else {
return 'icon-star-o server-is-not-favorite'
}
}
}

View File

@ -1,130 +1,370 @@
// Used by the individual server entries
var smallChartOptions = {
series: {
shadowSize: 0
},
xaxis: {
font: {
color: "#E3E3E3"
},
show: false
},
yaxis: {
minTickSize: 75,
tickDecimals: 0,
show: true,
tickLength: 10,
tickFormatter: function(value) {
return formatNumber(value);
},
font: {
color: "#E3E3E3"
},
labelWidth: -10
},
grid: {
hoverable: true,
color: "#696969"
},
colors: [
"#E9E581"
]
};
import { formatNumber, formatTimestamp, isMobileBrowser } from './util'
// Used by the one chart to rule them all
var bigChartOptions = {
series: {
shadowSize: 0
import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites'
export const HISTORY_GRAPH_OPTIONS = {
series: {
shadowSize: 0
},
xaxis: {
font: {
color: '#E3E3E3'
},
xaxis: {
font: {
color: "#E3E3E3"
},
show: false
show: false
},
yaxis: {
show: true,
tickSize: 5000,
tickLength: 10,
tickFormatter: formatNumber,
font: {
color: '#E3E3E3'
},
yaxis: {
show: true,
tickSize: 2000,
tickLength: 10,
tickFormatter: function(value) {
return formatNumber(value);
},
font: {
color: "#E3E3E3"
},
labelWidth: -5,
min: 0
},
grid: {
hoverable: true,
color: "#696969"
},
legend: {
show: false
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._hasLoadedSettings = false
this._initEventListenersOnce = false
this._showOnlyFavorites = false
}
addGraphPoint (serverId, timestamp, playerCount) {
if (!this._hasLoadedSettings) {
// _hasLoadedSettings is controlled by #setGraphData
// It will only be true once the context has been loaded and initial payload received
// #addGraphPoint should not be called prior to that since it means the data is racing
// and the application has received updates prior to the initial state
return
}
};
function toggleControlsDrawer() {
var div = $('#big-graph-controls-drawer');
// Trim any outdated entries by filtering the array into a new array
const startTimestamp = new Date().getTime()
const newGraphData = this._graphData[serverId].filter(point => startTimestamp - point[0] <= this._app.publicConfig.graphDuration)
div.css('display', div.css('display') !== 'none' ? 'none' : 'block');
}
// Push the new data from the method call request
newGraphData.push([timestamp, playerCount])
function saveGraphControls(displayedServers) {
if (typeof(localStorage)) {
var json = JSON.stringify(displayedServers);
this._graphData[serverId] = newGraphData
}
localStorage.setItem('displayedServers', json);
}
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY)
if (showOnlyFavorites) {
this._showOnlyFavorites = true
}
function loadGraphControls() {
if (typeof(localStorage)) {
var item = localStorage.getItem('displayedServers');
// If only favorites mode is active, use the stored favorite servers data instead
let serverNames
if (this._showOnlyFavorites) {
serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY)
} else {
serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY)
}
if (item) {
return JSON.parse(item);
}
}
}
if (serverNames) {
serverNames = JSON.parse(serverNames)
function resetGraphControls() {
if (typeof(localStorage)) {
localStorage.removeItem('displayedServers');
}
}
// Called by flot.js when they hover over a data point.
function handlePlotHover(event, pos, item) {
if (item) {
var text = getTimestamp(item.datapoint[0] / 1000) + '\
<br />\
' + formatNumber(item.datapoint[1]) + ' Players';
if (item.series && item.series.label) {
text = item.series.label + '<br />' + text;
// Iterate over all active serverRegistrations
// This merges saved state with current state to prevent desyncs
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
// isVisible will be true if showOnlyFavorites && contained in FAVORITE_SERVERS_STORAGE_KEY
// OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY
// Checks between FAVORITE/HIDDEN keys are mutually exclusive
if (this._showOnlyFavorites) {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0
} else {
serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0
}
}
}
}
}
renderTooltip(item.pageX + 5, item.pageY + 5, text);
updateLocalStorage () {
if (typeof localStorage !== 'undefined') {
// Mutate the serverIds array into server names for storage use
const serverNames = this._app.serverRegistry.getServerRegistrations()
.filter(serverRegistration => !serverRegistration.isVisible)
.map(serverRegistration => serverRegistration.data.name)
// Only store if the array contains data, otherwise clear the item
// If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead
if (serverNames.length > 0 && !this._showOnlyFavorites) {
localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames))
} else {
localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY)
}
// Only store SHOW_FAVORITES_STORAGE_KEY if true
if (this._showOnlyFavorites) {
localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true)
} else {
localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY)
}
}
}
// 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
}
})
}
buildPlotInstance (graphData) {
// Lazy load settings from localStorage, if any and if enabled
if (!this._hasLoadedSettings) {
this._hasLoadedSettings = true
this.loadLocalStorage()
}
// Remap the incoming data from being string (serverName) keyed into serverId keys
for (const serverName of Object.keys(graphData)) {
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName)
this._graphData[serverRegistration.serverId] = graphData[serverName]
}
// Explicitly define a height so flot.js can rescale the Y axis
document.getElementById('big-graph').style.height = '400px'
this._plotInstance = $.plot('#big-graph', this.getVisibleGraphData(), HISTORY_GRAPH_OPTIONS)
// Show the settings-toggle element
document.getElementById('settings-toggle').style.display = 'inline-block'
}
// requestRedraw allows usages to request a redraw that may be performed, or cancelled, sometime later
// This allows multiple rapid, but individual updates, to clump into a single redraw instead
requestRedraw () {
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
}
// Schedule new delayed redraw call
// This can be cancelled by #requestRedraw, #redraw and #reset
this._redrawRequestTimeout = setTimeout(this.redraw, 1000)
}
redraw = () => {
// Use drawing as a hint to update settings
// 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()
// undefine value so #clearTimeout is not called
// This is safe even if #redraw is manually called since it removes the pending work
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
}
this._redrawRequestTimeout = undefined
}
requestResize () {
// Only resize when _plotInstance is defined
// Set a timeout to resize after resize events have not been fired for some duration of time
// This prevents burning CPU time for multiple, rapid resize events
if (this._plotInstance) {
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
}
// Schedule new delayed resize call
// This can be cancelled by #requestResize, #resize and #reset
this._resizeRequestTimeout = setTimeout(this.resize, 200)
}
}
resize = () => {
if (this._plotInstance) {
this._plotInstance.resize()
this._plotInstance.setupGrid()
this._plotInstance.draw()
}
// undefine value so #clearTimeout is not called
// This is safe even if #resize is manually called since it removes the pending work
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
}
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 {
hideTooltip();
let text = formatNumber(item.datapoint[1]) + ' Players<br>' + formatTimestamp(item.datapoint[0])
// Prefix text with the series label when possible
if (item.series && item.series.label) {
text = '<strong>' + item.series.label + '</strong><br>' + text
}
this._app.tooltip.set(item.pageX, item.pageY, 10, 10, text)
}
}
initEventListeners () {
if (!this._initEventListenersOnce) {
this._initEventListenersOnce = true
// These listeners should only be init once since they attach to persistent elements
document.getElementById('settings-toggle').addEventListener('click', this.handleSettingsToggle, false)
document.querySelectorAll('.graph-controls-show').forEach((element) => {
element.addEventListener('click', this.handleShowButtonClick, false)
})
}
$('#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)
})
}
handleServerButtonClick = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
if (serverRegistration.isVisible !== event.target.checked) {
serverRegistration.isVisible = event.target.checked
// Any manual changes automatically disables "Only Favorites" mode
// Otherwise the auto management might overwrite their manual changes
this._showOnlyFavorites = false
this.redraw()
}
}
handleShowButtonClick = (event) => {
const showType = event.target.getAttribute('minetrack-show-type')
// If set to "Only Favorites", set internal state so that
// visible graphData is automatically updating when a ServerRegistration's #isVisible changes
// This is also saved and loaded by #loadLocalStorage & #updateLocalStorage
this._showOnlyFavorites = showType === 'favorites'
let redraw = false
this._app.serverRegistry.getServerRegistrations().forEach(function (serverRegistration) {
let isVisible
if (showType === 'all') {
isVisible = true
} else if (showType === 'none') {
isVisible = false
} else if (showType === 'favorites') {
isVisible = serverRegistration.isFavorite
}
if (serverRegistration.isVisible !== isVisible) {
serverRegistration.isVisible = isVisible
redraw = true
}
})
if (redraw) {
this.redraw()
this.updateCheckboxes()
}
}
handleSettingsToggle = () => {
const element = document.getElementById('big-graph-controls-drawer')
if (element.style.display !== 'block') {
element.style.display = 'block'
} else {
element.style.display = 'none'
}
}
handleServerIsFavoriteUpdate = (serverRegistration) => {
// When in "Only Favorites" mode, visibility is dependent on favorite status
// Redraw and update elements as needed
if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) {
serverRegistration.isVisible = serverRegistration.isFavorite
this.redraw()
this.updateCheckboxes()
}
}
updateCheckboxes () {
document.querySelectorAll('.graph-control').forEach((checkbox) => {
const serverId = parseInt(checkbox.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
checkbox.checked = serverRegistration.isVisible
})
}
reset () {
this._graphData = []
this._plotInstance = undefined
this._hasLoadedSettings = false
// Fire #clearTimeout if the timeout is currently defined
if (this._resizeRequestTimeout) {
clearTimeout(this._resizeRequestTimeout)
this._resizeRequestTimeout = undefined
}
if (this._redrawRequestTimeout) {
clearTimeout(this._redrawRequestTimeout)
this._redrawRequestTimeout = undefined
}
// Reset modified DOM structures
document.getElementById('big-graph-checkboxes').innerHTML = ''
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')
}
}
// Converts the backend data into the schema used by flot.js
function convertGraphData(rawData) {
var data = [];
var keys = Object.keys(rawData);
for (var i = 0; i < keys.length; i++) {
data.push({
data: rawData[keys[i]],
yaxis: 1,
label: keys[i],
color: getServerColor(keys[i])
});
}
return data;
}

159
assets/js/main.js Normal file
View File

@ -0,0 +1,159 @@
import { App } from './app'
import io from 'socket.io-client'
const app = new App()
document.addEventListener('DOMContentLoaded', function () {
const socket = io.connect({
reconnect: true,
reconnectDelay: 1000,
reconnectionAttempts: 10
})
// The backend will automatically push data once connected
socket.on('connect', function () {
app.caption.set('Loading...')
})
socket.on('disconnect', function () {
app.handleDisconnect()
// Reset modified DOM structures
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
})
socket.on('historyGraph', function (data) {
// 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(data)
// Build checkbox elements for graph controls
let lastRowCounter = 0
let controlsHTML = ''
Object.keys(data).sort().forEach(function (serverName) {
const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
controlsHTML += '<td>' +
'<input type="checkbox" class="graph-control" minetrack-server-id="' + serverRegistration.serverId + '" ' + (serverRegistration.isVisible ? 'checked' : '') + '>' +
' ' + serverName +
'</input></td>'
// Occasionally break table rows using a magic number
if (++lastRowCounter % 6 === 0) {
controlsHTML += '</tr><tr>'
}
})
// Apply generated HTML and show controls
document.getElementById('big-graph-checkboxes').innerHTML = '<table><tr>' +
controlsHTML +
'</tr></table>'
document.getElementById('big-graph-controls').style.display = 'block'
// Bind click event for updating graph data
app.graphDisplayManager.initEventListeners()
})
socket.on('updateHistoryGraph', function (data) {
// Skip any incoming updates if the graph is disabled
// The backend shouldn't send these anyways
if (!app.graphDisplayManager.isVisible) {
return
}
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
if (serverRegistration) {
app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.players)
// Only redraw the graph if not mutating hidden data
if (serverRegistration.isVisible) {
app.graphDisplayManager.requestRedraw()
}
}
})
socket.on('add', function (data) {
data.forEach(app.addServer)
})
socket.on('update', function (data) {
// 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(data.info.name)
if (serverRegistration) {
serverRegistration.updateServerStatus(data, false, app.publicConfig.minecraftVersions)
}
})
socket.on('updateMojangServices', function (data) {
Object.values(data).forEach(app.mojangUpdater.updateServiceStatus)
})
socket.on('setPublicConfig', function (data) {
app.setPublicConfig(data)
// 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 (data.isGraphVisible) {
if (app.graphDisplayManager.isVisible) {
socket.emit('requestHistoryGraph')
} else {
document.getElementById('big-graph-mobile-load-request').style.display = 'block'
}
}
})
// Fired once the backend has sent all requested data
socket.on('syncComplete', function () {
app.handleSyncComplete()
})
socket.on('updatePeak', function (data) {
const serverRegistration = app.serverRegistry.getServerRegistration(data.name)
if (serverRegistration) {
serverRegistration.updateServerPeak(data.timestamp, data.players)
}
})
socket.on('peaks', function (data) {
Object.keys(data).forEach(function (serverName) {
const serverRegistration = app.serverRegistry.getServerRegistration(serverName)
if (serverRegistration) {
const graphData = data[serverName]
// [0] and [1] indexes correspond to flot.js' graphing data structure
serverRegistration.updateServerPeak(graphData[0], graphData[1])
}
})
})
window.addEventListener('resize', function () {
app.percentageBar.redraw()
// 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
socket.emit('requestHistoryGraph')
// Hide the activation link to avoid multiple requests
document.getElementById('big-graph-mobile-load-request').style.display = 'none'
}, false)
}, false)

20
assets/js/mojang.js Normal file
View File

@ -0,0 +1,20 @@
const MOJANG_STATUS_BASE_CLASS = 'header-button header-button-group'
export class MojangUpdater {
updateServiceStatus (status) {
// HACK: ensure mojang-status is added for alignment, replace existing class to swap status color
document.getElementById('mojang-status_' + status.name).setAttribute('class', MOJANG_STATUS_BASE_CLASS + ' mojang-status-' + status.title.toLowerCase())
document.getElementById('mojang-status-text_' + status.name).innerText = status.title
}
reset () {
// Strip any mojang-status-* color classes from all mojang-status classes
document.querySelectorAll('.mojang-status').forEach(function (element) {
element.setAttribute('class', MOJANG_STATUS_BASE_CLASS)
})
document.querySelectorAll('.mojang-status-text').forEach(function (element) {
element.innerText = '...'
})
}
}

72
assets/js/percbar.js Normal file
View File

@ -0,0 +1,72 @@
import { formatNumber, formatPercent } from './util'
export class PercentageBar {
constructor (app) {
this._app = app
this._parent = document.getElementById('perc-bar')
}
redraw = () => {
const serverRegistrations = this._app.serverRegistry.getServerRegistrations().sort(function (a, b) {
return a.playerCount - b.playerCount
})
const totalPlayers = this._app.getTotalPlayerCount()
let leftPadding = 0
for (const serverRegistration of serverRegistrations) {
const width = Math.round((serverRegistration.playerCount / totalPlayers) * this._parent.offsetWidth)
// Update position/width
// leftPadding is a sum of previous iterations width value
const div = document.getElementById('perc-bar-part_' + serverRegistration.serverId) || this.createPart(serverRegistration)
// Only redraw if needed
if (div.style.width !== width + 'px' || div.style.left !== leftPadding + 'px') {
div.style.width = width + 'px'
div.style.left = leftPadding + 'px'
}
leftPadding += width
}
}
createPart (serverRegistration) {
const div = document.createElement('div')
div.id = 'perc-bar-part_' + serverRegistration.serverId
div.style.background = serverRegistration.data.color
div.setAttribute('class', 'perc-bar-part')
div.setAttribute('minetrack-server-id', serverRegistration.serverId)
this._parent.appendChild(div)
// Define events once during creation
div.addEventListener('mouseover', this.handleMouseOver, false)
div.addEventListener('mouseout', this.handleMouseOut, false)
return div
}
handleMouseOver = (event) => {
const serverId = parseInt(event.target.getAttribute('minetrack-server-id'))
const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId)
this._app.tooltip.set(event.target.offsetLeft, event.target.offsetTop, 10, this._parent.offsetTop + this._parent.offsetHeight + 10,
(typeof serverRegistration.rankIndex !== 'undefined' ? '#' + (serverRegistration.rankIndex + 1) + ' ' : '') +
serverRegistration.data.name +
'<br>' + formatNumber(serverRegistration.playerCount) + ' Players<br>' +
'<strong>' + formatPercent(serverRegistration.playerCount, this._app.getTotalPlayerCount()) + '</strong>')
}
handleMouseOut = () => {
this._app.tooltip.hide()
}
reset () {
// Reset modified DOM elements
this._parent.innerHTML = ''
}
}

304
assets/js/servers.js Normal file
View File

@ -0,0 +1,304 @@
import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions, isArrayEqual, isObjectEqual } from './util'
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
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
}
assignServers (servers) {
for (let i = 0; i < servers.length; i++) {
const data = servers[i]
this._serverIdsByName[data.name] = i
this._serverDataById[i] = data
}
}
createServerRegistration (serverName) {
const serverId = this._serverIdsByName[serverName]
const serverData = this._serverDataById[serverId]
const serverRegistration = new ServerRegistration(this._app, serverId, serverData)
this._registeredServers[serverId] = serverRegistration
return serverRegistration
}
getServerRegistration (serverKey) {
if (typeof serverKey === 'string') {
const serverId = this._serverIdsByName[serverKey]
return this._registeredServers[serverId]
} else if (typeof serverKey === 'number') {
return this._registeredServers[serverKey]
}
}
getServerRankBy (serverRegistration, x, sort) {
const records = Object.values(this._registeredServers)
.map(x)
.filter(val => val !== undefined)
// Invalidate any results that do not account for all serverRegistrations
if (records.length === this._registeredServers.length) {
records.sort(sort)
// Pull matching data from target serverRegistration
// Assume indexOf cannot be -1 or val undefined since they have been pre-tested in the map call above
const val = x(serverRegistration)
const indexOf = records.indexOf(val)
return indexOf + 1
}
}
getServerRegistrations = () => Object.values(this._registeredServers)
reset () {
this._serverIdsByName = []
this._serverDataById = []
this._registeredServers = []
// Reset modified DOM structures
document.getElementById('server-list').innerHTML = ''
}
}
const SERVER_GRAPH_DATA_MAX_LENGTH = 72
export class ServerRegistration {
playerCount = 0
isVisible = true
isFavorite = false
rankIndex
lastRecordData
lastVersions = []
lastPeakData
constructor (app, serverId, data) {
this._app = app
this.serverId = serverId
this.data = data
this._graphData = []
this._failedSequentialPings = 0
}
addGraphPoints (points) {
// Test if the first point contains error.placeholder === true
// This is sent by the backend when the server hasn't been pinged yet
// These points will be disregarded to prevent the graph starting at 0 player count
points = points.filter(point => !point.error || !point.error.placeholder)
// The backend should never return more data elements than the max
// but trim the data result regardless for safety and performance purposes
if (points.length > SERVER_GRAPH_DATA_MAX_LENGTH) {
points.slice(points.length - SERVER_GRAPH_DATA_MAX_LENGTH, points.length)
}
this._graphData = points.map(point => point.result ? [point.timestamp, point.result.players.online] : [point.timestamp, 0])
}
buildPlotInstance () {
this._plotInstance = $.plot('#chart_' + this.serverId, [this._graphData], SERVER_GRAPH_OPTIONS)
}
handlePing (payload, pushToGraph) {
if (payload.result) {
this.playerCount = payload.result.players.online
if (pushToGraph) {
// Only update graph for successful pings
// This intentionally pauses the server graph when pings begin to fail
this._graphData.push([payload.info.timestamp, this.playerCount])
// Trim graphData to within the max length by shifting out the leading elements
if (this._graphData.length > SERVER_GRAPH_DATA_MAX_LENGTH) {
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
} else {
// Attempt to retain a copy of the cached playerCount for up to N failed pings
// This prevents minor connection issues from constantly reshuffling the layout
if (++this._failedSequentialPings > 5) {
this.playerCount = 0
}
}
}
redraw () {
// Redraw the plot instance
this._plotInstance.setData([this._graphData])
this._plotInstance.setupGrid()
this._plotInstance.draw()
}
updateServerRankIndex (rankIndex) {
this.rankIndex = rankIndex
document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1)
}
updateServerPeak (time, playerCount) {
const peakLabelElement = document.getElementById('peak_' + this.serverId)
// Always set label once any peak data has been received
peakLabelElement.style.display = 'block'
const peakValueElement = document.getElementById('peak-value_' + this.serverId)
peakValueElement.innerText = formatNumber(playerCount)
peakLabelElement.title = 'At ' + formatTimestamp(time)
this.lastPeakData = {
timestamp: time,
playerCount: playerCount
}
}
updateServerStatus (ping, isInitialUpdate, minecraftVersions) {
// Only pushToGraph when initialUpdate === false
// Otherwise the ping value is pushed into the graphData when already present
this.handlePing(ping, !isInitialUpdate)
// Compare against a cached value to avoid empty updates
// Allow undefined ping.versions inside the if statement for text reset handling
if (ping.versions && !isArrayEqual(ping.versions, this.lastVersions)) {
this.lastVersions = ping.versions
const versionsElement = document.getElementById('version_' + this.serverId)
versionsElement.style.display = 'block'
versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[ping.info.type]) || ''
}
// Compare against a cached value to avoid empty updates
if (ping.recordData !== undefined && !isObjectEqual(ping.recordData, this.lastRecordData, ['playerCount', 'timestamp'])) {
this.lastRecordData = ping.recordData
// Always set label once any record data has been received
const recordLabelElement = document.getElementById('record_' + this.serverId)
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 !== -1) {
recordValueElement.innerHTML = formatNumber(recordData.playerCount) + ' (' + formatDate(recordData.timestamp) + ')'
recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestamp(recordData.timestamp)
} else {
recordValueElement.innerText = formatNumber(recordData.playerCount)
}
}
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'
// Attempt to find an error cause from documented options
errorElement.innerText = ping.error.description || ping.error.errno || 'Unknown error'
} else if (ping.result) {
// 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.result.players.online)
// An updated favicon has been sent, update the src
// Ignore calls from 'add' events since they will have explicitly manually handled the favicon update
if (!isInitialUpdate && ping.favicon) {
document.getElementById('favicon_' + this.serverId).setAttribute('src', ping.favicon)
}
}
}
initServerStatus (latestPing) {
const peakHourDuration = Math.floor(this._app.publicConfig.graphDuration / (60 * 60 * 1000)) + 'h Peak: '
const serverElement = document.createElement('div')
serverElement.id = 'container_' + this.serverId
serverElement.innerHTML = '<div class="column column-favicon">' +
'<img class="server-favicon" src="' + (latestPing.favicon || MISSING_FAVICON) + '" id="favicon_' + this.serverId + '" title="' + this.data.name + '\n' + formatMinecraftServerAddress(this.data.ip, this.data.port) + '">' +
'<span class="server-rank" id="ranking_' + this.serverId + '"></span>' +
'</div>' +
'<div class="column column-status">' +
'<h3 class="server-name"><span class="' + this._app.favoritesManager.getIconClass(this.isFavorite) + '" id="favorite-toggle_' + this.serverId + '"></span> ' + this.data.name + '</h3>' +
'<span class="server-error" id="error_' + this.serverId + '"></span>' +
'<span class="server-label" id="player-count_' + this.serverId + '">Players: <span class="server-value" id="player-count-value_' + this.serverId + '"></span></span>' +
'<span class="server-label" id="peak_' + this.serverId + '">' + peakHourDuration + '<span class="server-value" id="peak-value_' + this.serverId + '">-</span></span>' +
'<span class="server-label" id="record_' + this.serverId + '">Record: <span class="server-value" id="record-value_' + this.serverId + '">-</span></span>' +
'<span class="server-label" id="version_' + this.serverId + '"></span>' +
'</div>' +
'<div class="column column-graph" id="chart_' + this.serverId + '"></div>'
serverElement.setAttribute('class', 'server')
document.getElementById('server-list').appendChild(serverElement)
}
updateHighlightedValue (selectedCategory) {
['player-count', 'peak', 'record'].forEach((category) => {
const labelElement = document.getElementById(category + '_' + this.serverId)
const valueElement = document.getElementById(category + '-value_' + this.serverId)
if (selectedCategory && category === selectedCategory) {
labelElement.setAttribute('class', 'server-highlighted-label')
valueElement.setAttribute('class', 'server-highlighted-value')
} else {
labelElement.setAttribute('class', 'server-label')
valueElement.setAttribute('class', 'server-value')
}
})
}
initEventListeners () {
$('#chart_' + this.serverId).bind('plothover', this._app.graphDisplayManager.handlePlotHover)
document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => {
this._app.favoritesManager.handleFavoriteButtonClick(this)
}, false)
}
}

View File

@ -1,538 +0,0 @@
var graphs = [];
var lastPlayerEntries = [];
var historyPlot;
var displayedGraphData;
var hiddenGraphData = [];
var isConnected = false;
var mojangServicesUpdater;
var sortServersTask;
var currentServerHover;
var faviconSize = 64;
function updateServerStatus(lastEntry) {
var info = lastEntry.info;
var div = $('#status_' + safeName(info.name));
var versionDiv = $('#version_' + safeName(info.name));
if (lastEntry.versions) {
var versions = '';
for (var i = 0; i < lastEntry.versions.length; i++) {
if (!lastEntry.versions[i]) continue;
versions += '<span class="version">' + publicConfig.minecraftVersions[lastEntry.info.type][lastEntry.versions[i]] + '</span>&nbsp;';
}
versionDiv.html(versions);
} else {
versionDiv.html('');
}
if (lastEntry.result) {
var result = lastEntry.result;
var newStatus = 'Players: <span style="font-weight: 500;">' + formatNumber(result.players.online) + '</span>';
var listing = graphs[lastEntry.info.name].listing;
if (listing.length > 0) {
newStatus += '<span class="color-gray"> (';
var playerDifference = listing[listing.length - 1][1] - listing[0][1];
if (playerDifference >= 0) {
newStatus += '+';
}
newStatus += playerDifference + ')</span>';
}
lastPlayerEntries[info.name] = result.players.online;
div.html(newStatus);
} else {
var newStatus = '<span class="color-red">';
if (findErrorMessage(lastEntry.error)) {
newStatus += findErrorMessage(lastEntry.error);
} else {
newStatus += 'Failed to ping!';
}
div.html(newStatus + '</span>');
}
var keys = Object.keys(lastPlayerEntries);
var totalPlayers = 0;
for (var i = 0; i < keys.length; i++) {
totalPlayers += lastPlayerEntries[keys[i]];
}
$("#stat_totalPlayers").text(formatNumber(totalPlayers));
$("#stat_networks").text(formatNumber(keys.length));
if (lastEntry.record) {
$('#record_' + safeName(info.name)).html('Record: ' + formatNumber(lastEntry.record));
}
updatePercentageBar();
}
function sortServers() {
var serverNames = [];
var keys = Object.keys(lastPlayerEntries);
for (var i = 0; i < keys.length; i++) {
serverNames.push(keys[i]);
}
serverNames.sort(function(a, b) {
return (lastPlayerEntries[b] || 0) - (lastPlayerEntries[a] || 0);
});
for (var i = 0; i < serverNames.length; i++) {
$('#container_' + safeName(serverNames[i])).appendTo('#server-container-list');
$('#ranking_' + safeName(serverNames[i])).text('#' + (i + 1));
}
}
function updatePercentageBar() {
var keys = Object.keys(lastPlayerEntries);
keys.sort(function(a, b) {
return lastPlayerEntries[a] - lastPlayerEntries[b];
});
var totalPlayers = getCurrentTotalPlayers();
var parent = $('#perc-bar');
var leftPadding = 0;
for (var i = 0; i < keys.length; i++) {
(function(pos, server, length) {
var safeNameCopy = safeName(server);
var playerCount = lastPlayerEntries[server];
var div = $('#perc_bar_part_' + safeNameCopy);
// Setup the base
if (!div.length) {
$('<div/>', {
id: 'perc_bar_part_' + safeNameCopy,
class: 'perc-bar-part',
html: '',
style: 'background: ' + getServerColor(server) + ';'
}).appendTo(parent);
div = $('#perc_bar_part_' + safeNameCopy);
div.mouseover(function(e) {
currentServerHover = server;
});
div.mouseout(function(e) {
hideTooltip();
currentServerHover = undefined;
});
}
// Update our position/width
var width = (playerCount / totalPlayers) * parent.width();
div.css({
width: width + 'px',
left: leftPadding + 'px'
});
leftPadding += width;
})(i, keys[i], keys.length);
}
}
function getCurrentTotalPlayers() {
var totalPlayers = 0;
var keys = Object.keys(lastPlayerEntries);
for (var i = 0; i < keys.length; i++) totalPlayers += lastPlayerEntries[keys[i]]
return totalPlayers;
}
function setAllGraphVisibility(visible) {
if (visible) {
var keys = Object.keys(hiddenGraphData);
for (var i = 0; i < keys.length; i++) {
displayedGraphData[keys[i]] = hiddenGraphData[keys[i]];
}
hiddenGraphData = [];
} else {
var keys = Object.keys(displayedGraphData);
for (var i = 0; i < keys.length; i++) {
hiddenGraphData[keys[i]] = displayedGraphData[keys[i]];
}
displayedGraphData = [];
}
$('.graph-control').each(function(index, item) {
item.checked = visible;
});
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
// Update our localStorage
if (visible) {
resetGraphControls();
} else {
saveGraphControls(Object.keys(displayedGraphData));
}
}
function validateBootTime(bootTime, socket) {
$('#tagline-text').text('Validating...');
console.log('Remote bootTime is ' + bootTime + ', local is ' + publicConfig.bootTime);
if (bootTime === publicConfig.bootTime) {
$('#tagline-text').text('Loading...');
socket.emit('requestListing');
if (!isMobileBrowser()) socket.emit('requestHistoryGraph');
isConnected = true;
// Start any special updating tasks.
mojangServicesUpdater = setInterval(updateMojangServices, 1000);
sortServersTask = setInterval(sortServers, 10000);
} else {
$('#tagline-text').text('Updating...');
$.getScript('/publicConfig.json', function(data, textStatus, xhr) {
if (xhr.status === 200) {
validateBootTime(publicConfig.bootTime, socket);
} else {
showCaption('Failed to update! Refresh?');
}
});
}
}
function printPort(port) {
if(port == undefined || port == 25565) {
return "";
} else {
return ":" + port;
}
}
function updateServerPeak(name, time, playerCount) {
var safeNameCopy = safeName(name);
// hack: strip the AM/PM suffix
// Javascript doesn't have a nice way to format Dates with AM/PM, so we'll append it manually
var timestamp = getTimestamp(time / 1000).split(':');
var end = timestamp.pop().split(' ')[1];
timestamp = timestamp.join(':');
// end may be undefined for other timezones/24 hour times
if (end) {
timestamp += ' ' + end;
}
var timeLabel = msToTime(publicConfig.graphDuration);
$('#peak_' + safeNameCopy).html(timeLabel + ' Peak: ' + formatNumber(playerCount) + ' @ ' + timestamp);
}
$(document).ready(function() {
var socket = io.connect({
reconnect: true,
reconnectDelay: 1000,
reconnectionAttempts: 10
});
socket.on('bootTime', function(bootTime) {
validateBootTime(bootTime, socket);
});
socket.on('disconnect', function() {
if (mojangServicesUpdater) clearInterval(mojangServicesUpdater);
if (sortServersTask) clearInterval(sortServersTask);
lastMojangServiceUpdate = undefined;
showCaption('Disconnected! Refresh?');
lastPlayerEntries = {};
graphs = {};
$('#server-container-list').html('');
$('#big-graph').html('');
$('#big-graph-checkboxes').html('');
$('#big-graph-controls').css('display', 'none');
$('#perc-bar').html('');
$('.mojang-status').css('background', 'transparent');
$('.mojang-status-text').text('...');
$("#stat_totalPlayers").text(0);
$("#stat_networks").text(0);
isConnected = false;
});
socket.on('historyGraph', function(rawData) {
var shownServers = loadGraphControls();
if (shownServers) {
var keys = Object.keys(rawData);
hiddenGraphData = [];
displayedGraphData = [];
for (var i = 0; i < keys.length; i++) {
var name = keys[i];
if (shownServers.indexOf(name) !== -1) {
displayedGraphData[name] = rawData[name];
} else {
hiddenGraphData[name] = rawData[name];
}
}
} else {
displayedGraphData = rawData;
}
$('#big-graph').css('height', '400px');
historyPlot = $.plot('#big-graph', convertGraphData(displayedGraphData), bigChartOptions);
$('#big-graph').bind('plothover', handlePlotHover);
var keys = Object.keys(rawData);
var sinceBreak = 0;
var html = '<table><tr>';
keys.sort();
for (var i = 0; i < keys.length; i++) {
var checkedString = '';
if (displayedGraphData[keys[i]]) {
checkedString = 'checked=checked';
}
html += '<td><input type="checkbox" class="graph-control" id="graph-controls" data-target-network="' + keys[i] + '" ' + checkedString + '> ' + keys[i] + '</input></td>';
if (sinceBreak >= 7) {
sinceBreak = 0;
html += '</tr><tr>';
} else {
sinceBreak++;
}
}
$('#big-graph-checkboxes').append(html + '</tr></table>');
$('#big-graph-controls').css('display', 'block');
});
socket.on('updateHistoryGraph', function(rawData) {
// Prevent race conditions.
if (!displayedGraphData || !hiddenGraphData) {
return;
}
// If it's not in our display group, use the hidden group instead.
var targetGraphData = displayedGraphData[rawData.name] ? displayedGraphData : hiddenGraphData;
trimOldPings(targetGraphData, publicConfig.graphDuration);
targetGraphData[rawData.name].push([rawData.timestamp, rawData.players]);
// Redraw if we need to.
if (displayedGraphData[rawData.name]) {
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
}
});
socket.on('add', function(servers) {
for (var i = 0; i < servers.length; i++) {
var history = servers[i];
var listing = [];
for (var x = 0; x < history.length; x++) {
var point = history[x];
if (point.result) {
listing.push([point.timestamp, point.result.players.online]);
} else if (point.error) {
listing.push([point.timestamp, 0]);
}
}
var lastEntry = history[history.length - 1];
var info = lastEntry.info;
if (lastEntry.error) {
lastPlayerEntries[info.name] = 0;
} else if (lastEntry.result) {
lastPlayerEntries[info.name] = lastEntry.result.players.online;
}
var typeString = publicConfig.serverTypesVisible ? '<span class="type">' + info.type + '</span>' : '';
var safeNameCopy = safeName(info.name);
$('<div/>', {
id: 'container_' + safeNameCopy,
class: 'server',
'server-id': safeNameCopy,
html: '<div id="server-' + safeNameCopy + '" class="column" style="width: 80px;">\
<img id="favicon_' + safeNameCopy + '" title="' + info.name + '\n' + info.ip + printPort(info.port) + '" height="' + faviconSize + '" width="' + faviconSize + '">\
<br />\
<p class="text-center-align rank" id="ranking_' + safeNameCopy + '"></p>\
</div>\
<div class="column" style="width: 282px;">\
<h3>' + info.name + '&nbsp;' + typeString + '</h3>\
<span id="status_' + safeNameCopy + '">Waiting</span>\
<div id="version_' + safeNameCopy + '" class="color-dark-gray server-meta versions"><span class="version"></span></div>\
<span id="peak_' + safeNameCopy + '" class="color-dark-gray server-meta"></span>\
<br><span id="record_' + safeNameCopy + '" class="color-dark-gray server-meta"></span>\
</div>\
<div class="column" style="float: right;">\
<div class="chart" id="chart_' + safeNameCopy + '"></div>\
</div>'
}).appendTo("#server-container-list");
var favicon = MISSING_FAVICON_BASE64;
if (lastEntry.result && lastEntry.result.favicon) {
favicon = lastEntry.result.favicon;
}
$('#favicon_' + safeName(info.name)).attr('src', favicon);
graphs[lastEntry.info.name] = {
listing: listing,
plot: $.plot('#chart_' + safeNameCopy, [listing], smallChartOptions)
};
updateServerStatus(lastEntry);
$('#chart_' + safeNameCopy).bind('plothover', handlePlotHover);
}
sortServers();
updatePercentageBar();
});
socket.on('update', function(update) {
// Prevent weird race conditions.
if (!graphs[update.info.name]) {
return;
}
// We have a new favicon, update the old one.
if (update.result && update.result.favicon) {
$('#favicon_' + safeName(update.info.name)).attr('src', update.result.favicon);
}
var graph = graphs[update.info.name];
updateServerStatus(update);
if (update.result) {
graph.listing.push([update.info.timestamp, update.result ? update.result.players.online : 0]);
if (graph.listing.length > 72) {
graph.listing.shift();
}
graph.plot.setData([graph.listing]);
graph.plot.setupGrid();
graph.plot.draw();
}
});
socket.on('updateMojangServices', function(data) {
if (isConnected) {
updateMojangServices(data);
}
});
socket.on('syncComplete', function() {
hideCaption();
});
socket.on('updatePeak', function(data) {
updateServerPeak(data.name, data.timestamp, data.players);
});
socket.on('peaks', function(data) {
var keys = Object.keys(data);
for (var i = 0; i < keys.length; i++) {
var val = data[keys[i]];
updateServerPeak(keys[i], val[0], val[1]);
}
});
$(document).on('click', '.graph-control', function(e) {
var serverIp = $(this).attr('data-target-network');
// Restore it, or delete it - either works.
if (!this.checked) {
hiddenGraphData[serverIp] = displayedGraphData[serverIp];
delete displayedGraphData[serverIp];
} else {
displayedGraphData[serverIp] = hiddenGraphData[serverIp];
delete hiddenGraphData[serverIp];
}
// Redraw the graph
historyPlot.setData(convertGraphData(displayedGraphData));
historyPlot.setupGrid();
historyPlot.draw();
// Update our localStorage
if (Object.keys(hiddenGraphData).length === 0) {
resetGraphControls();
} else {
saveGraphControls(Object.keys(displayedGraphData));
}
});
$(document).on('mousemove', function(e) {
if (currentServerHover) {
var totalPlayers = getCurrentTotalPlayers();
var playerCount = lastPlayerEntries[currentServerHover];
renderTooltip(e.pageX + 10, e.pageY + 10, '<strong>' + currentServerHover + '</strong>: ' + roundToPoint(playerCount / totalPlayers * 100, 10) + '% of ' + formatNumber(totalPlayers) + ' tracked players.<br />(' + formatNumber(playerCount) + ' online.)');
}
});
$(window).on('resize', function() {
updatePercentageBar();
if (historyPlot) {
historyPlot.resize();
historyPlot.setupGrid();
historyPlot.draw();
}
});
});

185
assets/js/sort.js Normal file
View File

@ -0,0 +1,185 @@
import { isArrayEqual } from './util'
const SORT_OPTIONS = [
{
getName: () => 'Players',
sortFunc: (a, b) => b.playerCount - a.playerCount,
highlightedValue: 'player-count'
},
{
getName: (app) => {
return Math.floor(app.publicConfig.graphDuration / (60 * 60 * 1000)) + 'h Peak'
},
sortFunc: (a, b) => {
if (!a.lastPeakData && !b.lastPeakData) {
return 0
} else if (a.lastPeakData && !b.lastPeakData) {
return -1
} else if (b.lastPeakData && !a.lastPeakData) {
return 1
}
return b.lastPeakData.playerCount - a.lastPeakData.playerCount
},
testFunc: (app) => {
// Require at least one ServerRegistration to have a lastPeakData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
if (serverRegistration.lastPeakData) {
return true
}
}
return false
},
highlightedValue: 'peak'
},
{
getName: () => 'Record',
sortFunc: (a, b) => {
if (!a.lastRecordData && !b.lastRecordData) {
return 0
} else if (a.lastRecordData && !b.lastRecordData) {
return -1
} else if (b.lastRecordData && !a.lastRecordData) {
return 1
}
return b.lastRecordData.playerCount - a.lastRecordData.playerCount
},
testFunc: (app) => {
// Require at least one ServerRegistration to have a lastRecordData value defined
for (const serverRegistration of app.serverRegistry.getServerRegistrations()) {
if (serverRegistration.lastRecordData) {
return true
}
}
return false
},
highlightedValue: 'record'
}
]
const SORT_OPTION_INDEX_DEFAULT = 0
const SORT_OPTION_INDEX_STORAGE_KEY = 'minetrack_sort_option_index'
export class SortController {
constructor (app) {
this._app = app
this._buttonElement = document.getElementById('sort-by')
this._textElement = document.getElementById('sort-by-text')
this._sortOptionIndex = SORT_OPTION_INDEX_DEFAULT
}
reset () {
this._lastSortedServers = undefined
// Reset modified DOM structures
this._buttonElement.style.display = 'none'
this._textElement.innerText = '...'
// Remove bound DOM event listeners
this._buttonElement.removeEventListener('click', this.handleSortByButtonClick)
}
loadLocalStorage () {
if (typeof localStorage !== 'undefined') {
const sortOptionIndex = localStorage.getItem(SORT_OPTION_INDEX_STORAGE_KEY)
if (sortOptionIndex) {
this._sortOptionIndex = parseInt(sortOptionIndex)
}
}
}
updateLocalStorage () {
if (typeof localStorage !== 'undefined') {
if (this._sortOptionIndex !== SORT_OPTION_INDEX_DEFAULT) {
localStorage.setItem(SORT_OPTION_INDEX_STORAGE_KEY, this._sortOptionIndex)
} else {
localStorage.removeItem(SORT_OPTION_INDEX_STORAGE_KEY)
}
}
}
show () {
// Load the saved option selection, if any
this.loadLocalStorage()
this.updateSortOption()
// Bind DOM event listeners
// This is removed by #reset to avoid multiple listeners
this._buttonElement.addEventListener('click', this.handleSortByButtonClick)
// Show #sort-by element
this._buttonElement.style.display = 'inline-block'
}
handleSortByButtonClick = () => {
while (true) {
// Increment to the next sort option, wrap around if needed
this._sortOptionIndex = (this._sortOptionIndex + 1) % SORT_OPTIONS.length
// Only break if the sortOption is supported
// This can technically cause an infinite loop, but never should assuming
// at least one sortOption does not implement the test OR always returns true
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
if (!sortOption.testFunc || sortOption.testFunc(this._app)) {
break
}
}
// Redraw the button and sort the servers
this.updateSortOption()
// Save the updated option selection
this.updateLocalStorage()
}
updateSortOption = () => {
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
// Pass app instance so sortOption names can be dynamically generated
this._textElement.innerText = sortOption.getName(this._app)
// Update all servers highlighted values
for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) {
serverRegistration.updateHighlightedValue(sortOption.highlightedValue)
}
this.sortServers()
}
sortServers = () => {
const sortOption = SORT_OPTIONS[this._sortOptionIndex]
const sortedServers = this._app.serverRegistry.getServerRegistrations().sort((a, b) => {
if (a.isFavorite && !b.isFavorite) {
return -1
} else if (b.isFavorite && !a.isFavorite) {
return 1
}
return sortOption.sortFunc(a, b)
})
// Test if sortedServers has changed from the previous listing
// This avoids DOM updates and graphs being redrawn
const sortedServerIds = sortedServers.map(server => server.serverId)
if (isArrayEqual(sortedServerIds, this._lastSortedServers)) {
return
}
this._lastSortedServers = sortedServerIds
// Sort a ServerRegistration list by the sortOption ONLY
// This is used to determine the ServerRegistration's rankIndex without #isFavorite skewing values
const rankIndexSort = this._app.serverRegistry.getServerRegistrations().sort(sortOption.sortFunc)
// Update the DOM structure
sortedServers.forEach(function (serverRegistration) {
$('#container_' + serverRegistration.serverId).appendTo('#server-list')
// Set the ServerRegistration's rankIndex to its indexOf the normal sort
serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration))
})
}
}

View File

@ -1,189 +1,160 @@
var MISSING_FAVICON_BASE64 = "data:image/png;base64,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";
export class Tooltip {
constructor () {
this._div = document.getElementById('tooltip')
}
var tooltip = $('#tooltip');
set (x, y, offsetX, offsetY, html) {
this._div.innerHTML = html
var lastMojangServiceUpdate;
var publicConfig;
// Assign display: block so that the offsetWidth is valid
this._div.style.display = 'block'
function showCaption(html) {
var tagline = $('#tagline-text');
tagline.stop(true, false);
tagline.html(html);
tagline.slideDown(100);
}
// Prevent the div from overflowing the page width
const tooltipWidth = this._div.offsetWidth
function hideCaption() {
var tagline = $('#tagline-text');
tagline.stop(true, false);
tagline.slideUp(100);
}
function setPublicConfig(json) {
publicConfig = json;
$('#server-container-list').html('');
}
function getServerByField(id, value) {
for (var i = 0; i < publicConfig.servers.length; i++) {
var entry = publicConfig.servers[i];
if (entry[id] === value) {
return entry;
}
}
}
function getServerByIp(ip) {
return getServerByField('ip', ip);
}
function getServerByName(name) {
return getServerByField('name', name);
}
function getServerColor(name) {
var server = getServerByName(name);
return server ? server.color : stringToColor(name);
}
// Generate (and set) the HTML that displays Mojang status.
// If nothing is passed, re-render the last update.
// If something is passed, update and then re-render.
function updateMojangServices(currentUpdate) {
if (currentUpdate) {
lastMojangServiceUpdate = currentUpdate;
// 1.2 is a magic number used to pad the offset to ensure the tooltip
// never gets close or surpasses the page's X width
if (x + offsetX + (tooltipWidth * 1.2) > window.innerWidth) {
x -= tooltipWidth
offsetX *= -1
}
if (!lastMojangServiceUpdate) {
return;
this._div.style.top = (y + offsetY) + 'px'
this._div.style.left = (x + offsetX) + 'px'
}
hide = () => {
this._div.style.display = 'none'
}
}
export class Caption {
constructor () {
this._div = document.getElementById('status-text')
}
set (text) {
this._div.innerText = text
this._div.style.display = 'block'
}
hide () {
this._div.style.display = 'none'
}
}
// Minecraft Java Edition default server port: 25565
// Minecraft Bedrock Edition default server port: 19132
const MINECRAFT_DEFAULT_PORTS = [25565, 19132]
export function formatMinecraftServerAddress (ip, port) {
if (port && !MINECRAFT_DEFAULT_PORTS.includes(port)) {
return ip + ':' + port
}
return ip
}
// Detect gaps in versions by matching their indexes to knownVersions
export function formatMinecraftVersions (versions, knownVersions) {
if (!versions || !versions.length || !knownVersions || !knownVersions.length) {
return
}
let currentVersionGroup = []
const versionGroups = []
for (let i = 0; i < versions.length; i++) {
const versionIndex = versions[i]
// Look for value mismatch between the previous index
// Require i > 0 since lastVersionIndex is undefined for i === 0
if (i > 0 && versions[i] - 1 !== versionIndex - 1) {
versionGroups.push(currentVersionGroup)
currentVersionGroup = []
}
var keys = Object.keys(lastMojangServiceUpdate);
currentVersionGroup.push(versionIndex)
}
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var status = lastMojangServiceUpdate[key];
// Ensure the last versionGroup is always pushed
if (currentVersionGroup.length > 0) {
versionGroups.push(currentVersionGroup)
}
// hack: ensure mojang-status is added for alignment, replace existing class to swap status color
$('#mojang-status_' + status.name).attr('class', 'mojang-status mojang-status-' + status.title.toLowerCase());
$('#mojang-status-text_' + status.name).text(status.title);
if (versionGroups.length === 0) {
return
}
// Remap individual versionGroups values into named versions
return versionGroups.map(versionGroup => {
const startVersion = knownVersions[versionGroup[0]]
if (versionGroup.length === 1) {
// A versionGroup may contain a single version, only return its name
// This is a cosmetic catch to avoid version labels like 1.0-1.0
return startVersion
} else {
const endVersion = knownVersions[versionGroup[versionGroup.length - 1]]
return startVersion + '-' + endVersion
}
}).join(', ')
}
function findErrorMessage(error) {
if (error.description) {
return error.description;
} else if (error.errno) {
return error.errno;
export function formatTimestamp (millis) {
const date = new Date(0)
date.setUTCSeconds(millis / 1000)
return date.toLocaleTimeString()
}
export function formatDate (millis) {
const date = new Date(0)
date.setUTCSeconds(millis / 1000)
return date.toLocaleDateString()
}
export function formatPercent (x, over) {
const val = Math.round((x / over) * 100 * 10) / 10
return val + '%'
}
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
}
function getTimestamp(ms, timeOnly) {
var date = new Date(0);
export function isObjectEqual (a, b, props) {
if (typeof a === 'undefined' || typeof a !== typeof b) {
return false
}
for (let i = 0; i < props.length; i++) {
const prop = props[i]
date.setUTCSeconds(ms);
return date.toLocaleTimeString();
}
function safeName(name) {
return name.replace(/ /g, '');
}
function renderTooltip(x, y, html) {
tooltip.html(html).css({
top: y,
left: x
}).fadeIn(0);
}
function hideTooltip() {
tooltip.hide();
}
function formatNumber(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
if (typeof a[prop] === 'undefined' || typeof a[prop] !== typeof b[prop] || a[prop] !== b[prop]) {
return false
}
}
return true
}
// From http://detectmobilebrowsers.com/
function isMobileBrowser() {
export function isMobileBrowser () {
var check = false;
(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;
}
function trimOldPings(data, graphDuration) {
var keys = Object.keys(data);
var timeMs = new Date().getTime();
for (var x = 0; x < keys.length; x++) {
var listing = data[keys[x]];
var toSplice = [];
for (var i = 0; i < listing.length; i++) {
var entry = listing[i];
if (timeMs - entry[0] > graphDuration) {
toSplice.push(i);
}
}
for (var i = 0; i < toSplice.length; i++) {
listing.splice(toSplice[i], 1);
}
}
}
function stringToColor(base) {
var hash;
for (var i = base.length - 1, hash = 0; i >= 0; i--) {
hash = base.charCodeAt(i) + ((hash << 5) - hash);
}
color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16);
return '#' + Array(6 - color.length + 1).join('0') + color;
}
function roundToPoint(val, scale) {
return Math.round(val * scale) / scale;
}
function msToTime(timer) {
var milliseconds = timer % 1000;
timer = (timer - milliseconds) / 1000;
var seconds = timer % 60;
timer = (timer - seconds) / 60;
var minutes = timer % 60;
var hours = (timer - minutes) / 60;
var days = Math.floor(hours / 24);
hours -= days * 24;
var string = '';
// hack: only format days if >1, if === 1 it will format as "24h" instead
if (days > 1) {
string += days + 'd';
} else if (days === 1) {
hours += 24;
}
if (hours > 0) {
string += hours + 'h';
}
if (minutes > 0) {
string += minutes + 'm';
}
if (seconds > 0) {
string += seconds + 's';
}
return string;
// 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
}