feat: full de-Angularization with Bootstrap 5 migration
- Removed AngularJS 1.4.8 completely — no more framework dependency - Extracted core RCON logic into pure vanilla JS (js/rcon.js) - Added lightweight hash-based SPA router (js/router.js) - Rewrote all 6 pages as vanilla JS modules in js/pages/ - Migrated Bootstrap 3 → Bootstrap 5 (cards instead of panels, bootstrap-icons instead of glyphicons) - Removed jQuery dependency (Bootstrap 5 is vanilla JS) - Purged all Angular templates from html/ - Removed npm artifacts (package.json, node_modules) - Added nginx.example.conf for static hosting - Updated rcon.css dark theme for BS5 variables - Static file only — no Node.js backend required Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Chat page — live chat viewer with message sending.
|
||||
*/
|
||||
function ChatPage(params, container) {
|
||||
var address = Rcon.getAddress() || '';
|
||||
|
||||
var html =
|
||||
'<div id="ChatController">' +
|
||||
'<div id="chat-output" style="height:600px" class="Output scrollable"></div>' +
|
||||
'<form id="chat-form">' +
|
||||
'<input id="chat-input" class="form-control" style="width:100%" placeholder="Type a message...">' +
|
||||
'</form>' +
|
||||
'</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
var outputEl = document.getElementById('chat-output');
|
||||
var inputEl = document.getElementById('chat-input');
|
||||
|
||||
function addMessage(msg) {
|
||||
var div = document.createElement('div');
|
||||
var t = new Date((msg.Time || 0) * 1000);
|
||||
var timeStr = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
var ts = document.createElement('span');
|
||||
ts.style.cssText = 'min-width:60px;display:inline-block;';
|
||||
ts.textContent = timeStr;
|
||||
div.appendChild(ts);
|
||||
|
||||
var ns = document.createElement('span');
|
||||
ns.style.cssText = 'min-width:130px;display:inline-block;text-align:right;';
|
||||
var a = document.createElement('a');
|
||||
a.href = '#/' + address + '/player/' + (msg.UserId || '');
|
||||
a.textContent = stripHtml(msg.Username || '') + ' :';
|
||||
ns.appendChild(a);
|
||||
div.appendChild(ns);
|
||||
|
||||
var ms = document.createElement('span');
|
||||
ms.style.display = 'inline-block';
|
||||
ms.textContent = stripHtml(msg.Message || '');
|
||||
div.appendChild(ms);
|
||||
|
||||
outputEl.appendChild(div);
|
||||
if (isScrolledToBottom(outputEl)) outputEl.scrollTop = outputEl.scrollHeight;
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
var cmd = inputEl.value.trim();
|
||||
if (!cmd) return;
|
||||
Rcon.command('say ' + cmd, 1);
|
||||
inputEl.value = '';
|
||||
}
|
||||
|
||||
function onMessage(msg) {
|
||||
if (msg.Type !== 'Chat') return;
|
||||
addMessage(JSON.parse(msg.Message));
|
||||
}
|
||||
|
||||
function fetchHistory() {
|
||||
Rcon.request('chat.tail 512', function (response) {
|
||||
var messages = JSON.parse(response.Message);
|
||||
for (var i = 0; i < messages.length; i++) addMessage(messages[i]);
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('chat-form').addEventListener('submit', function (e) { e.preventDefault(); onSubmit(); });
|
||||
Rcon.on('message', onMessage);
|
||||
|
||||
if (Rcon.isConnected()) fetchHistory();
|
||||
else { var _c = function () { fetchHistory(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
||||
|
||||
return function cleanup() { Rcon.off('message', onMessage); };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Connection page — connect form with localStorage persistence.
|
||||
*/
|
||||
function ConnectPage(params, container) {
|
||||
var html =
|
||||
'<div class="row">' +
|
||||
'<div class="offset-md-4 col-md-6" style="margin-top: 128px">' +
|
||||
'<div class="card p-4 mb-4">' +
|
||||
'<h2>Connect</h2>' +
|
||||
'<div class="alert alert-danger" id="connect-error" style="display:none"></div>' +
|
||||
'<p>Enter the address (including rcon port) and the rcon password below to connect.</p>' +
|
||||
'<form id="connect-form">' +
|
||||
'<div class="mb-3">' +
|
||||
'<label for="connection-address" class="form-label">Address</label>' +
|
||||
'<input required minlength="5" id="connection-address" class="form-control" placeholder="xxx.xxx.xxx.xxx:28016">' +
|
||||
'</div>' +
|
||||
'<div class="mb-3">' +
|
||||
'<label for="connection-password" class="form-label">Password</label>' +
|
||||
'<input required minlength="2" type="password" id="connection-password" class="form-control">' +
|
||||
'</div>' +
|
||||
'<div class="form-check float-end">' +
|
||||
'<input type="checkbox" class="form-check-input" id="connection-save" checked>' +
|
||||
'<label class="form-check-label" for="connection-save">Save Connection</label>' +
|
||||
'</div>' +
|
||||
'<button type="submit" class="btn btn-primary w-100">Connect</button>' +
|
||||
'</form>' +
|
||||
'</div>' +
|
||||
'<div class="card p-4">' +
|
||||
'<div id="saved-connections"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
var addrInput = document.getElementById('connection-address');
|
||||
var passInput = document.getElementById('connection-password');
|
||||
var saveCheck = document.getElementById('connection-save');
|
||||
var errorDiv = document.getElementById('connect-error');
|
||||
var savedDiv = document.getElementById('saved-connections');
|
||||
var connectionsLimit = 5;
|
||||
|
||||
function loadConnections() {
|
||||
try { return JSON.parse(localStorage.previousConnections || '[]'); }
|
||||
catch (e) { return []; }
|
||||
}
|
||||
|
||||
function saveConnections(conns) {
|
||||
localStorage.previousConnections = JSON.stringify(conns);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorDiv.textContent = msg;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideError() { errorDiv.style.display = 'none'; }
|
||||
|
||||
function addConnection(addr, pass) {
|
||||
var conns = loadConnections();
|
||||
conns = conns.filter(function (c) { return c.Address !== addr; });
|
||||
conns.push({ Address: addr, Password: pass, date: new Date().toISOString() });
|
||||
saveConnections(conns);
|
||||
}
|
||||
|
||||
function doConnect(addr, pass) {
|
||||
hideError();
|
||||
Rcon.connect(addr, pass);
|
||||
}
|
||||
|
||||
function renderSaved() {
|
||||
var conns = loadConnections();
|
||||
if (conns.length === 0) { savedDiv.innerHTML = ''; return; }
|
||||
var display = conns.slice().reverse();
|
||||
var shown = connectionsLimit || display.length;
|
||||
var h = '<h2>Saved Connections</h2><ul>';
|
||||
for (var i = 0; i < Math.min(shown, display.length); i++) {
|
||||
var c = display[i];
|
||||
h += '<li><a href="#" class="saved-conn" data-addr="' + c.Address + '" data-pass="' + c.Password + '">' + c.Address + '</a> — ' + new Date(c.date).toLocaleString() + '</li>';
|
||||
}
|
||||
h += '</ul>';
|
||||
if (conns.length > 5) {
|
||||
h += '<button type="button" class="btn btn-primary btn-block" id="toggle-conn-limit">Show ' + (connectionsLimit ? 'more' : 'less') + '</button>';
|
||||
}
|
||||
savedDiv.innerHTML = h;
|
||||
|
||||
var links = savedDiv.querySelectorAll('.saved-conn');
|
||||
for (var j = 0; j < links.length; j++) {
|
||||
links[j].addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
doConnect(this.dataset.addr, this.dataset.pass);
|
||||
});
|
||||
}
|
||||
var toggle = document.getElementById('toggle-conn-limit');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
connectionsLimit = connectionsLimit ? undefined : 5;
|
||||
renderSaved();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('connect-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var addr = addrInput.value.trim();
|
||||
var pass = passInput.value.trim();
|
||||
if (!addr || !pass) return;
|
||||
if (saveCheck.checked) addConnection(addr, pass);
|
||||
doConnect(addr, pass);
|
||||
});
|
||||
|
||||
function onConnected() {
|
||||
window.location.hash = '#/' + Rcon.getAddress() + '/info';
|
||||
}
|
||||
function onDisconnected(ev) {
|
||||
showError('Connection was closed — Error ' + (ev ? ev.code : 'unknown'));
|
||||
}
|
||||
|
||||
Rcon.on('connected', onConnected);
|
||||
Rcon.on('disconnected', onDisconnected);
|
||||
renderSaved();
|
||||
|
||||
// Auto-connect from URL — check hash for address and password
|
||||
var hash = window.location.hash;
|
||||
var pwMatch = hash.match(/[?&]password=([^&]+)/);
|
||||
if (pwMatch) {
|
||||
passInput.value = decodeURIComponent(pwMatch[1]);
|
||||
window.location.hash = hash.replace(/[?&]password=[^&]+/, '');
|
||||
}
|
||||
var addrMatch = hash.match(/^#\/([^/]+)\//);
|
||||
if (addrMatch) addrInput.value = addrMatch[1];
|
||||
|
||||
if (addrInput.value && passInput.value) {
|
||||
if (saveCheck.checked) addConnection(addrInput.value.trim(), passInput.value.trim());
|
||||
doConnect(addrInput.value.trim(), passInput.value.trim());
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
Rcon.off('connected', onConnected);
|
||||
Rcon.off('disconnected', onDisconnected);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Console page — live console output with command input and history.
|
||||
*/
|
||||
function ConsolePage(params, container) {
|
||||
var html =
|
||||
'<div id="ConsoleController">' +
|
||||
'<div id="console-output" style="height:600px" class="Output scrollable"></div>' +
|
||||
'<form id="console-form">' +
|
||||
'<input type="text" id="console-input" class="form-control" autocomplete="off">' +
|
||||
'</form>' +
|
||||
'</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
var outputEl = document.getElementById('console-output');
|
||||
var inputEl = document.getElementById('console-input');
|
||||
var commandHistory = [];
|
||||
var historyIndex = 0;
|
||||
|
||||
function addOutput(msg) {
|
||||
var div = document.createElement('div');
|
||||
div.className = msg.Type;
|
||||
div.textContent = stripHtml(msg.Message);
|
||||
outputEl.appendChild(div);
|
||||
if (isScrolledToBottom(outputEl)) outputEl.scrollTop = outputEl.scrollHeight;
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
var cmd = inputEl.value.trim();
|
||||
if (!cmd) return;
|
||||
addOutput({ Message: cmd, Type: 'Command' });
|
||||
commandHistory.push(cmd);
|
||||
historyIndex = commandHistory.length;
|
||||
Rcon.command(cmd, 1);
|
||||
inputEl.value = '';
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (commandHistory.length === 0) return;
|
||||
if (e.keyCode === 38) { // Up
|
||||
historyIndex--;
|
||||
if (historyIndex < 0) historyIndex = commandHistory.length - 1;
|
||||
inputEl.value = commandHistory[historyIndex] || '';
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === 40) { // Down
|
||||
historyIndex++;
|
||||
if (historyIndex >= commandHistory.length) historyIndex = 0;
|
||||
inputEl.value = commandHistory[historyIndex] || '';
|
||||
e.preventDefault();
|
||||
} else {
|
||||
historyIndex = commandHistory.length;
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(msg) {
|
||||
if (msg.Message && msg.Message.indexOf('[rcon] ') === 0) return;
|
||||
switch (msg.Type) {
|
||||
case 'Generic': case 'Log': case 'Error': case 'Warning':
|
||||
addOutput(msg); break;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchHistory() {
|
||||
Rcon.request('console.tail 128', function (response) {
|
||||
var messages = JSON.parse(response.Message);
|
||||
for (var i = 0; i < messages.length; i++) onMessage(messages[i]);
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('console-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault(); onSubmit();
|
||||
});
|
||||
inputEl.addEventListener('keydown', onKeyDown);
|
||||
Rcon.on('message', onMessage);
|
||||
|
||||
if (Rcon.isConnected()) fetchHistory();
|
||||
else { var _c = function () { fetchHistory(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
||||
|
||||
return function cleanup() { Rcon.off('message', onMessage); };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Player info page — single player detail with quirk fixes.
|
||||
*/
|
||||
function PlayerInfoPage(params, container) {
|
||||
var userid = params[2]; // SteamID from URL
|
||||
|
||||
var html =
|
||||
'<div id="PlayerInfoController">' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-lg-12">' +
|
||||
'<h2 class="text-center">' +
|
||||
'Player: <span id="pi-username">' + userid + '</span>' +
|
||||
'<button id="pi-refresh" class="btn btn-secondary float-end" type="button"><i class="bi bi-arrow-clockwise"></i></button>' +
|
||||
'</h2>' +
|
||||
'<div class="d-grid mb-3">' +
|
||||
'<a href="http://www.steamcommunity.com/profiles/' + userid + '" target="_blank" class="btn btn-secondary">' +
|
||||
'<i class="bi bi-info-circle-fill"></i> Steam Profile' +
|
||||
'</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="col-lg-12">' +
|
||||
'<h2 class="text-center">Info</h2>' +
|
||||
'<div id="pi-notfound" class="alert alert-warning" style="display:none">This Player is currently not connected to this server!</div>' +
|
||||
'</div>' +
|
||||
'<div id="pi-fields" class="col-sm-12"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
function refresh() {
|
||||
Rcon.getPlayers(function (players) {
|
||||
var info = null;
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
if (players[i].SteamID === userid) { info = players[i]; break; }
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
document.getElementById('pi-notfound').style.display = 'block';
|
||||
document.getElementById('pi-fields').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('pi-notfound').style.display = 'none';
|
||||
|
||||
// Fix known Rust RCON quirk: VoiationLevel → ViolationLevel
|
||||
if (info.VoiationLevel !== undefined) {
|
||||
info.ViolationLevel = info.VoiationLevel;
|
||||
delete info.VoiationLevel;
|
||||
}
|
||||
|
||||
// Remove XP remnants
|
||||
delete info.CurrentLevel;
|
||||
delete info.UnspentXp;
|
||||
|
||||
document.getElementById('pi-username').textContent = info.DisplayName || userid;
|
||||
|
||||
var fieldsHtml = '';
|
||||
for (var key in info) {
|
||||
if (info.hasOwnProperty(key)) {
|
||||
fieldsHtml += '<div class="col-sm-4"><p>' + key + ': <span class="badge">' + info[key] + '</span></p></div>';
|
||||
}
|
||||
}
|
||||
document.getElementById('pi-fields').innerHTML = fieldsHtml;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('pi-refresh').addEventListener('click', refresh);
|
||||
|
||||
if (Rcon.isConnected()) refresh();
|
||||
else { var _c = function () { refresh(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Player list page — table with sort, filter, and kick.
|
||||
*/
|
||||
function PlayerListPage(params, container) {
|
||||
var address = Rcon.getAddress() || '';
|
||||
|
||||
var html =
|
||||
'<div id="PlayerListController">' +
|
||||
'<div class="row mb-3">' +
|
||||
'<div class="col-6">' +
|
||||
'<input id="player-filter" class="form-control" placeholder="Filter..">' +
|
||||
'</div>' +
|
||||
'<div class="col-6">' +
|
||||
'<button id="player-refresh" class="btn btn-secondary float-end" type="button"><i class="bi bi-arrow-clockwise"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-sm-12">' +
|
||||
'<div class="table-responsive">' +
|
||||
'<table class="table" id="player-table">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>Actions</th>' +
|
||||
'<th class="sortable" data-field="DisplayName">Username</th>' +
|
||||
'<th class="sortable text-end" data-field="SteamID">Steam ID</th>' +
|
||||
'<th class="sortable text-end" data-field="VoiationLevel">Violation Level</th>' +
|
||||
'<th class="sortable text-end" data-field="Address">Address</th>' +
|
||||
'<th class="sortable text-end" data-field="Ping">Ping</th>' +
|
||||
'<th class="sortable text-end" data-field="ConnectedSeconds">Duration</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody id="player-tbody"></tbody>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
var players = [];
|
||||
var orderBy = '-ConnectedSeconds';
|
||||
var filterText = '';
|
||||
|
||||
function sortPlayers(a, b) {
|
||||
var desc = orderBy[0] === '-';
|
||||
var field = desc ? orderBy.substring(1) : orderBy;
|
||||
var va = a[field], vb = b[field];
|
||||
if (typeof va === 'string') va = va.toLowerCase();
|
||||
if (typeof vb === 'string') vb = vb.toLowerCase();
|
||||
if (va < vb) return desc ? 1 : -1;
|
||||
if (va > vb) return desc ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function matchesFilter(p) {
|
||||
if (!filterText) return true;
|
||||
var ft = filterText.toLowerCase();
|
||||
return (p.DisplayName && p.DisplayName.toLowerCase().indexOf(ft) !== -1) ||
|
||||
(p.SteamID && p.SteamID.indexOf(ft) !== -1) ||
|
||||
(p.Address && p.Address.toLowerCase().indexOf(ft) !== -1);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var filtered = players.filter(matchesFilter).sort(sortPlayers);
|
||||
var tbody = document.getElementById('player-tbody');
|
||||
var html = '';
|
||||
for (var i = 0; i < filtered.length; i++) {
|
||||
var p = filtered[i];
|
||||
var fadeClass = (p.VoiationLevel === 0) ? ' class="fade"' : '';
|
||||
html +=
|
||||
'<tr>' +
|
||||
'<td>' +
|
||||
'<div class="btn-group">' +
|
||||
'<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">' +
|
||||
'<i class="bi bi-gear-fill"></i>' +
|
||||
'</button>' +
|
||||
'<ul class="dropdown-menu">' +
|
||||
'<li><a class="dropdown-item" href="http://www.steamcommunity.com/profiles/' + p.SteamID + '" target="_blank"><i class="bi bi-info-circle-fill"></i> Steam Profile</a></li>' +
|
||||
'<li><hr class="dropdown-divider"></li>' +
|
||||
'<li><a class="dropdown-item kick-player" href="#" data-id="' + p.SteamID + '"><i class="bi bi-trash-fill"></i> Kick</a></li>' +
|
||||
'</ul>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td><a href="#/' + address + '/player/' + p.SteamID + '/">' + (p.DisplayName || '') + '</a></td>' +
|
||||
'<td class="text-end"><a href="#/' + address + '/player/' + p.SteamID + '/">' + p.SteamID + '</a></td>' +
|
||||
'<td class="text-end"' + fadeClass + '>' + (p.VoiationLevel || 0) + '</td>' +
|
||||
'<td class="text-end">' + (p.Address || '') + '</td>' +
|
||||
'<td class="text-end">' + (p.Ping || 0) + '</td>' +
|
||||
'<td class="text-end">' + secondsToDuration(p.ConnectedSeconds || 0) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
|
||||
// Bind kick buttons
|
||||
var kicks = tbody.querySelectorAll('.kick-player');
|
||||
for (var j = 0; j < kicks.length; j++) {
|
||||
kicks[j].addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
Rcon.command('kick ' + this.dataset.id);
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Rcon.getPlayers(function (list) {
|
||||
players = list;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortHeaders() {
|
||||
var headers = document.querySelectorAll('#player-table .sortable');
|
||||
for (var i = 0; i < headers.length; i++) {
|
||||
headers[i].className = headers[i].className.replace(/sorting(\s+descending)?/g, '').trim();
|
||||
var field = headers[i].dataset.field;
|
||||
if (orderBy === field) headers[i].className += ' sorting';
|
||||
else if (orderBy === '-' + field) headers[i].className += ' sorting descending';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('player-refresh').addEventListener('click', refresh);
|
||||
document.getElementById('player-filter').addEventListener('input', function () {
|
||||
filterText = this.value;
|
||||
render();
|
||||
});
|
||||
|
||||
// Sort header clicks
|
||||
var sortHeaders = document.querySelectorAll('#player-table .sortable');
|
||||
for (var k = 0; k < sortHeaders.length; k++) {
|
||||
sortHeaders[k].addEventListener('click', function () {
|
||||
var field = this.dataset.field;
|
||||
orderBy = (orderBy === field) ? '-' + field : field;
|
||||
updateSortHeaders();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
if (Rcon.isConnected()) { refresh(); updateSortHeaders(); }
|
||||
else { var _c = function () { refresh(); updateSortHeaders(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Server info page — live dashboard with D3/NVD3 charts.
|
||||
*/
|
||||
function ServerInfoPage(params, container) {
|
||||
var DATA_LIMIT = 100;
|
||||
var recordedData = [];
|
||||
|
||||
var html =
|
||||
'<div id="ServerInfoController">' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-sm-12"><h2 id="si-hostname">Server Info</h2></div>' +
|
||||
'<div id="si-fields" class="col-sm-12"></div>' +
|
||||
'</div>' +
|
||||
'<div class="row">' +
|
||||
'<div class="col-sm-12">' +
|
||||
'<div class="card mb-3">' +
|
||||
'<div class="card-header">' +
|
||||
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#performanceCollapse">toggle</button>' +
|
||||
'<h4 class="card-title mb-0">Server Performance</h4>' +
|
||||
'</div>' +
|
||||
'<div id="performanceCollapse" class="collapse show">' +
|
||||
'<div class="card-body"><svg id="chart-performance"></svg></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card mb-3">' +
|
||||
'<div class="card-header">' +
|
||||
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#networkCollapse">toggle</button>' +
|
||||
'<h4 class="card-title mb-0">Server Networking</h4>' +
|
||||
'</div>' +
|
||||
'<div id="networkCollapse" class="collapse show">' +
|
||||
'<div class="card-body"><svg id="chart-network"></svg></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card mb-3">' +
|
||||
'<div class="card-header">' +
|
||||
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#playersCollapse">toggle</button>' +
|
||||
'<h4 class="card-title mb-0">Players</h4>' +
|
||||
'</div>' +
|
||||
'<div id="playersCollapse" class="collapse show">' +
|
||||
'<div class="card-body"><svg id="chart-players"></svg></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// ---- NVD3 Charts ----
|
||||
nv.addGraph(function () {
|
||||
var perfChart = nv.models.multiChart()
|
||||
.margin({ top: 30, right: 75, bottom: 40, left: 75 })
|
||||
.color(['darkred', 'yellowgreen'])
|
||||
.useInteractiveGuideline(true)
|
||||
.duration(0);
|
||||
perfChart.yAxis1.axisLabel('Framerate').tickFormat(d3.format(',d'));
|
||||
perfChart.yAxis2.axisLabel('Entities').tickFormat(d3.format(',d'));
|
||||
perfChart.xAxis.axisLabel('Time').tickFormat(function (d) { return d3.time.format('%H:%M:%S')(new Date(d)); });
|
||||
perfChart.lines1.duration(0);
|
||||
perfChart.lines2.duration(0);
|
||||
|
||||
d3.select('#chart-performance').datum([
|
||||
{ key: 'Framerate', type: 'line', yAxis: 1, values: [] },
|
||||
{ key: 'Entities', type: 'line', yAxis: 2, values: [] }
|
||||
]).call(perfChart);
|
||||
|
||||
window._perfChart = perfChart;
|
||||
});
|
||||
|
||||
nv.addGraph(function () {
|
||||
var netChart = nv.models.stackedAreaChart()
|
||||
.margin({ top: 0, right: 75, bottom: 40, left: 75 })
|
||||
.useInteractiveGuideline(true)
|
||||
.duration(0);
|
||||
netChart.yAxis.axisLabel('Network').tickFormat(function (bytes) {
|
||||
var fmt = d3.format('.0f');
|
||||
if (bytes < 1024) return fmt(bytes) + 'B';
|
||||
if (bytes < 1048576) return fmt(bytes / 1024) + 'kB';
|
||||
if (bytes < 1073741824) return fmt(bytes / 1048576) + 'MB';
|
||||
return fmt(bytes / 1073741824) + 'GB';
|
||||
});
|
||||
netChart.xAxis.axisLabel('Time').tickFormat(function (d) { return d3.time.format('%H:%M:%S')(new Date(d)); });
|
||||
|
||||
d3.select('#chart-network').datum([
|
||||
{ key: 'IN', values: [] },
|
||||
{ key: 'OUT', values: [] }
|
||||
]).call(netChart);
|
||||
|
||||
window._netChart = netChart;
|
||||
});
|
||||
|
||||
nv.addGraph(function () {
|
||||
var playersChart = nv.models.pieChart()
|
||||
.x(function (d) { return d.key; })
|
||||
.y(function (d) { return d.y; })
|
||||
.showLabels(true)
|
||||
.donut(true)
|
||||
.duration(500)
|
||||
.height(250)
|
||||
.margin({ top: 0, right: 75, bottom: 0, left: 75 });
|
||||
playersChart.pie.startAngle(function (d) { return d.startAngle / 2 - Math.PI / 2; });
|
||||
playersChart.pie.endAngle(function (d) { return d.endAngle / 2 - Math.PI / 2; });
|
||||
|
||||
d3.select('#chart-players').datum([]).call(playersChart);
|
||||
|
||||
window._playersChart = playersChart;
|
||||
});
|
||||
|
||||
// ---- Data polling ----
|
||||
var pollTimer = null;
|
||||
|
||||
function refresh() {
|
||||
Rcon.request('serverinfo', function (msg) {
|
||||
updateData(JSON.parse(msg.Message));
|
||||
});
|
||||
}
|
||||
|
||||
function updateData(data) {
|
||||
// Update text fields
|
||||
var fieldsHtml = '';
|
||||
for (var key in data) {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
var val = data[key];
|
||||
if (typeof val === 'number') val = formatNumber(val);
|
||||
fieldsHtml += '<div class="col-sm-4"><p><span class="text-muted">' + key + ':</span> <span>' + val + '</span></p></div>';
|
||||
}
|
||||
}
|
||||
document.getElementById('si-fields').innerHTML = fieldsHtml;
|
||||
if (data.Hostname) document.getElementById('si-hostname').textContent = data.Hostname;
|
||||
|
||||
// Collect chart data
|
||||
recordedData.push({ ts: Date.now(), data: data });
|
||||
if (recordedData.length > DATA_LIMIT) {
|
||||
recordedData = recordedData.slice(recordedData.length - DATA_LIMIT);
|
||||
}
|
||||
updateCharts(data);
|
||||
}
|
||||
|
||||
function updateCharts(data) {
|
||||
// Players pie chart
|
||||
if (window._playersChart) {
|
||||
d3.select('#chart-players').datum([
|
||||
{ key: 'Queued', y: data.Queued || 0 },
|
||||
{ key: 'Joining', y: data.Joining || 0 },
|
||||
{ key: 'Players', y: data.Players || 0 },
|
||||
{ key: 'Free', y: (data.MaxPlayers || 0) - ((data.Joining || 0) + (data.Players || 0)) }
|
||||
]).call(window._playersChart);
|
||||
}
|
||||
|
||||
// Performance chart
|
||||
var fpsVals = [], entVals = [], netInVals = [], netOutVals = [];
|
||||
for (var i = 0; i < recordedData.length; i++) {
|
||||
var r = recordedData[i];
|
||||
fpsVals.push({ x: r.ts, y: r.data.Framerate || 0 });
|
||||
entVals.push({ x: r.ts, y: r.data.EntityCount || 0 });
|
||||
netInVals.push({ x: r.ts, y: r.data.NetworkIn || 0 });
|
||||
netOutVals.push({ x: r.ts, y: r.data.NetworkOut || 0 });
|
||||
}
|
||||
|
||||
if (window._perfChart) {
|
||||
d3.select('#chart-performance').datum([
|
||||
{ key: 'Framerate', type: 'line', yAxis: 1, values: fpsVals },
|
||||
{ key: 'Entities', type: 'line', yAxis: 2, values: entVals }
|
||||
]).call(window._perfChart);
|
||||
}
|
||||
|
||||
if (window._netChart) {
|
||||
d3.select('#chart-network').datum([
|
||||
{ key: 'IN', values: netInVals },
|
||||
{ key: 'OUT', values: netOutVals }
|
||||
]).call(window._netChart);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
refresh();
|
||||
pollTimer = setInterval(refresh, 1000);
|
||||
}
|
||||
|
||||
if (Rcon.isConnected()) startPolling();
|
||||
else { var _c = function () { startPolling(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
||||
|
||||
return function cleanup() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
window._perfChart = null;
|
||||
window._netChart = null;
|
||||
window._playersChart = null;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user