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:
2026-06-13 17:51:50 +02:00
co-authored by Copilot
parent 0fd94044a8
commit 0a178d17b9
32 changed files with 1242 additions and 1702 deletions
+93 -97
View File
@@ -1,114 +1,110 @@
var app = angular.module('RconApp', ['ngRoute', 'nvd3']);
/**
* Main application — layout management, header updates, sidebar nav, routing.
*/
(function () {
'use strict';
app.service('rconService', [RconService]);
var elConnect = document.getElementById('app-connect');
var elConnected = document.getElementById('app-connected');
var elHeader = document.getElementById('server-header');
var elSidebar = document.getElementById('sidebar-nav');
var elTitle = document.getElementById('page-title');
app.config(function($routeProvider) {
$routeProvider.when("/home", {Title: "Home"});
$routeProvider.when("/:address/info", {
Title: "Server",
templateUrl: "html/serverInfo.html",
Nav: true
});
$routeProvider.when("/:address/console", {
Title: "Console",
templateUrl: "html/console.html",
Nav: true
});
$routeProvider.when("/:address/chat", {
Title: "Chat",
templateUrl: "html/chat.html",
Nav: true
});
$routeProvider.when("/:address/playerlist", {
Title: "Player List",
templateUrl: "html/playerlist.html",
Nav: true
});
$routeProvider.when("/:address/player/:userid", {
Title: "Player Info",
templateUrl: "html/playerInfo.html"
});
$routeProvider.otherwise({redirectTo: '/home'});
});
// ---- Sidebar navigation ----
var navPages = [
{ title: 'Server', path: '/:address/info' },
{ title: 'Console', path: '/:address/console' },
{ title: 'Chat', path: '/:address/chat' },
{ title: 'Player List', path: '/:address/playerlist' }
];
app.controller('RconController', RconController);
function RconController($scope, $rootScope, rconService, $timeout, $route) {
$scope.$route = $route;
$scope.pages = $.map($route.routes, function(value, index) {
if (value.Nav) {
return [value];
function renderSidebar(address) {
var html = '';
for (var i = 0; i < navPages.length; i++) {
var url = navPages[i].path.replace(':address', address);
var linkCls = (window._currentPage === navPages[i].title) ? ' nav-link active' : 'nav-link';
html += '<li class="nav-item"><a class="' + linkCls + '" href="#' + url + '">' + navPages[i].title + '</a></li>';
}
});
$scope.OpenLeftMenu = function() {
$mdSidenav('left').toggle();
};
$scope.IsConnected = function() {
return rconService.IsConnected();
elSidebar.innerHTML = html;
}
$rootScope.Nav = function(url) {
return url.replace(":address", rconService.Address);
function setActivePage(title) {
window._currentPage = title;
elTitle.textContent = title;
if (Rcon.isConnected()) renderSidebar(Rcon.getAddress());
}
$rootScope.$on('$stateChangeStart', function(next, current) {
console.log(next);
});
// ---- Header update ----
var headerTimer = null;
rconService.OnOpen = function() {
$scope.Connected = true;
$scope.$broadcast("OnConnected");
$scope.$digest();
$scope.address = rconService.Address;
}
rconService.OnClose = function(ev) {
$scope.$broadcast("OnDisconnected", ev);
$scope.$digest();
}
rconService.OnError = function(ev) {
$scope.$broadcast("OnConnectionError", ev);
$scope.$digest();
}
rconService.OnMessage = function(msg) {
$scope.$apply(function() {
$scope.$broadcast("OnMessage", msg);
function updateHeader() {
Rcon.request('serverinfo', function (msg) {
var data = JSON.parse(msg.Message);
document.getElementById('header-hostname').textContent = data.Hostname || 'Rust Server';
document.getElementById('header-uptime').innerHTML = '<i class="bi bi-clock"></i> ' + secondsToDuration(data.Uptime);
document.getElementById('header-map').innerHTML = '<i class="bi bi-globe2"></i> ' + (data.Map || '');
document.getElementById('header-players').innerHTML = '<i class="bi bi-people-fill"></i> ' + formatNumber(data.Players) + '/' + formatNumber(data.MaxPlayers);
document.getElementById('header-fps').innerHTML = '<i class="bi bi-speedometer2"></i> ' + formatNumber(data.Framerate) + 'fps';
document.getElementById('header-entities').innerHTML = '<i class="bi bi-boxes"></i> ' + formatNumber(data.EntityCount);
});
}
$scope.Disconnect = function() {
function startHeaderUpdates() {
updateHeader();
headerTimer = setInterval(updateHeader, 1000);
}
function stopHeaderUpdates() {
if (headerTimer) { clearInterval(headerTimer); headerTimer = null; }
}
// ---- Disconnect button ----
document.getElementById('btn-disconnect').addEventListener('click', function () {
if (confirm('Do you really want to disconnect?')) {
rconService.Disconnect();
$scope.Connected = false;
$scope.address = '#/home';
Rcon.disconnect();
}
});
// ---- Layout toggling ----
function showConnected(address) {
elConnect.innerHTML = '';
elConnect.style.display = 'none';
elConnected.style.display = 'block';
elHeader.style.display = 'block';
renderSidebar(address);
startHeaderUpdates();
}
}
app.filter('SecondsToDuration', [SecondsToDuration]);
function SecondsToDuration() {
return function(input) {
input = parseInt(input);
var out = "";
var hours = Math.floor(input / 3600);
if (input > 3600)
out += hours + "h";
var minutes = Math.floor(input / 60) % 60;
if (input > 60)
out += minutes + "m";
var seconds = input % 60;
out += seconds + "s";
return out;
function showDisconnected() {
stopHeaderUpdates();
elConnected.style.display = 'none';
elHeader.style.display = 'none';
elConnect.style.display = 'block';
// Clear any stale router content
document.getElementById('app-view').innerHTML = '';
ConnectPage([], elConnect);
}
}
// ---- Events ----
Rcon.on('connected', function () {
var addr = Rcon.getAddress();
showConnected(addr);
Router.go('#/' + addr + '/info');
});
Rcon.on('disconnected', function () {
showDisconnected();
});
// ---- Router setup ----
Router.add(/^\/([^/]+)\/info$/, 'Server', function (p, c) { setActiveSidebar('Server'); ServerInfoPage(p, c); });
Router.add(/^\/([^/]+)\/console$/, 'Console', function (p, c) { setActiveSidebar('Console'); ConsolePage(p, c); });
Router.add(/^\/([^/]+)\/chat$/, 'Chat', function (p, c) { setActiveSidebar('Chat'); ChatPage(p, c); });
Router.add(/^\/([^/]+)\/playerlist$/, 'Player List', function (p, c) { setActiveSidebar('Player List'); PlayerListPage(p, c); });
Router.add(/^\/([^/]+)\/player\/(.+)$/, 'Player Info', function (p, c) { setActiveSidebar('Player Info'); PlayerInfoPage(p, c); });
Router.start('#app-view');
// ---- Initial state ----
showDisconnected();
})();
-87
View File
@@ -1,87 +0,0 @@
app.controller( 'ChatController', ChatController );
function ChatController( $scope, rconService, $timeout )
{
$scope.Output = [];
$scope.SubmitCommand = function ()
{
rconService.Command( "say " + $scope.Command, 1 );
$scope.Command = "";
}
$scope.$on( "OnMessage", function ( event, msg )
{
if ( msg.Type !== "Chat" ) return;
$scope.OnMessage( JSON.parse( msg.Message ) );
});
$scope.OnMessage = function( msg )
{
msg.Message = stripHtml(msg.Message);
msg.Username = stripHtml(msg.Username);
$scope.Output.push( msg );
if($scope.isOnBottom()) {
$scope.ScrollToBottom();
}
}
$scope.ScrollToBottom = function()
{
var element = $( "#ChatController .Output" );
$timeout( function() {
element.scrollTop( element.prop('scrollHeight') );
}, 50 );
}
$scope.isOnBottom = function()
{
// get jquery element
var element = $( "#ChatController .Output" );
// height of the element
var height = element.height();
// scroll position from top position
var scrollTop = element.scrollTop();
// full height of the element
var scrollHeight = element.prop('scrollHeight');
if((scrollTop + height) > (scrollHeight - 10)) {
return true;
}
return false;
}
//
// Calls console.tail - which returns the last 256 entries from the console.
// This is then added to the console
//
$scope.GetHistory = function ()
{
rconService.Request( "chat.tail 512", $scope, function ( msg )
{
var messages = JSON.parse( msg.Message );
messages.forEach( function ( message ) {
$scope.OnMessage( message );
});
$scope.ScrollToBottom();
} );
}
rconService.InstallService( $scope, $scope.GetHistory )
}
function stripHtml( text )
{
return text ? String(text).replace( /<[^>]+>/gm, '' ) : '';
}
-135
View File
@@ -1,135 +0,0 @@
app.controller( 'ConnectionController', ConnectionController );
function ConnectionController( $scope, rconService, $routeParams, $timeout, $location )
{
$scope.Address = "";
$scope.Password = "";
$scope.SaveConnection = true;
$scope.connectionsLimit = 5;
function _loadFromLocalStorage() {
var connections = [];
// load and parse from local storage
if ( localStorage && localStorage.previousConnections ) {
connections = angular.fromJson( localStorage.previousConnections );
}
// double check
if ( !connections ) {
connections = [];
}
return connections;
}
function _addWithoutDuplicates(connections, connection) {
// prepare new array
var filteredConnections = [];
for(var i in connections) {
if(connections[i].Address !== connection.Address && connections[i].Password !== connection.Password) {
// add old connection info to our new array
filteredConnections.push(connections[i]);
}
}
// add new connection
filteredConnections.push(connection);
return filteredConnections;
}
$scope.toggleConnectionsLimit = function ()
{
// toggle limit between undefined and 5
// undefined sets limit to max
if($scope.connectionsLimit === undefined) {
$scope.connectionsLimit = 5;
} else {
$scope.connectionsLimit = undefined;
}
}
$scope.Connect = function ()
{
$scope.Address = $scope.Address.trim();
$scope.Password = $scope.Password.trim();
$scope.LastErrorMessage = null;
rconService.Connect( $scope.Address, $scope.Password );
$location.path('/' + $scope.Address + '/info');
}
$scope.ConnectTo = function ( c )
{
$scope.SaveConnection = false;
$scope.LastErrorMessage = null;
rconService.Connect( c.Address, c.Password );
}
$scope.$on( "OnDisconnected", function ( x, ev )
{
console.log( ev );
$scope.LastErrorMessage = "Connection was closed - Error " + ev.code;
$scope.$digest();
} );
$scope.$on( "OnConnected", function ( x, ev )
{
if ( $scope.SaveConnection )
{
// new connection to add
var connection = { Address: $scope.Address, Password: $scope.Password, date: new Date() };
// remove old entries and add our new connection info
var connections = _addWithoutDuplicates(_loadFromLocalStorage(), connection);
// push to scope and save data
$scope.PreviousConnects = connections;
localStorage.previousConnections = angular.toJson( $scope.PreviousConnects );
}
} );
$scope.PreviousConnects = _loadFromLocalStorage();
//
// If a server address is passed in.. try to connect if we have a saved entry
//
$timeout( function ()
{
$scope.Address = $routeParams.address;
//
// If a password was passed as a search param, use that
//
var pw = $location.search().password;
if ( pw )
{
$scope.Password = pw;
$location.search( "password", null );
}
if ( $scope.Address != null )
{
// If we have a password (passed as a search param) then connect using that
if ( $scope.Password != "" )
{
$scope.Connect();
return;
}
var foundAddress = Enumerable.From( $scope.PreviousConnects ).Where( function ( x ) { return x.Address == $scope.Address } ).First();
if ( foundAddress != null )
{
$scope.ConnectTo( foundAddress );
}
}
}, 20 );
}
-147
View File
@@ -1,147 +0,0 @@
app.controller( 'ConsoleController', ConsoleController );
function ConsoleController( $scope, rconService, $timeout )
{
$scope.Output = [];
$scope.commandHistory = [];
$scope.commandHistoryIndex = 0;
$scope.KeyUp = function (event)
{
switch(event.keyCode) {
// Arrow Key Up
case 38:
// rotate through commandHistory
$scope.commandHistoryIndex--;
if($scope.commandHistoryIndex < 0) {
$scope.commandHistoryIndex = $scope.commandHistory.length;
}
// set command from history
if($scope.commandHistory[$scope.commandHistoryIndex]) {
$scope.Command = $scope.commandHistory[$scope.commandHistoryIndex];
}
break;
// Arrow Key Down
case 40:
// rotate through commandHistory
$scope.commandHistoryIndex++;
if($scope.commandHistoryIndex >= $scope.commandHistory.length) {
$scope.commandHistoryIndex = 0;
}
// set command from history
if($scope.commandHistory[$scope.commandHistoryIndex]) {
$scope.Command = $scope.commandHistory[$scope.commandHistoryIndex];
}
break;
default:
// reset command history index
$scope.commandHistoryIndex = $scope.commandHistory.length;
break;
}
}
$scope.SubmitCommand = function ()
{
$scope.OnMessage( { Message: $scope.Command, Type: 'Command' } );
$scope.commandHistory.push($scope.Command);
rconService.Command( $scope.Command, 1 );
$scope.Command = "";
$scope.commandHistoryIndex = 0;
}
$scope.$on( "OnMessage", function ( event, msg ) { $scope.OnMessage( msg ); } );
$scope.OnMessage = function( msg )
{
if ( msg.Message.startsWith( "[rcon] " ) ) {
return;
}
switch(msg.Type) {
case 'Generic':
case 'Log':
case 'Error':
case 'Warning':
$scope.addOutput(msg);
break;
default:
console.log( msg );
return;
}
}
$scope.ScrollToBottom = function()
{
var element = $( "#ConsoleController .Output" );
$timeout( function() {
element.scrollTop( element.prop('scrollHeight') );
}, 50 );
}
$scope.isOnBottom = function()
{
// get jquery element
var element = $( "#ConsoleController .Output" );
// height of the element
var height = element.height();
// scroll position from top position
var scrollTop = element.scrollTop();
// full height of the element
var scrollHeight = element.prop('scrollHeight');
if((scrollTop + height) > (scrollHeight - 10)) {
return true;
}
return false;
}
//
// Calls console.tail - which returns the last 128 entries from the console.
// This is then added to the console
//
$scope.GetHistory = function ()
{
rconService.Request( "console.tail 128", $scope, function ( msg )
{
var messages = JSON.parse( msg.Message );
messages.forEach( function ( msg ) {
$scope.OnMessage( msg );
});
$scope.ScrollToBottom();
} );
}
$scope.addOutput = function (msg)
{
msg.Class = msg.Type;
msg.Message = stripHtml(msg.Message);
$scope.Output.push( msg );
if($scope.isOnBottom()) {
$scope.ScrollToBottom();
}
}
rconService.InstallService( $scope, $scope.GetHistory )
}
-10
View File
File diff suppressed because one or more lines are too long
+73
View File
@@ -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); };
}
+142
View File
@@ -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);
};
}
+80
View File
@@ -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); };
}
+70
View File
@@ -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); }
}
+141
View File
@@ -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); }
}
+188
View File
@@ -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;
};
}
-55
View File
@@ -1,55 +0,0 @@
app.controller( 'PlayerInfoController', PlayerInfoController );
function PlayerInfoController( $scope, rconService, $routeParams )
{
$scope.userid = $routeParams.userid;
$scope.info = null;
$scope.refresh = function ()
{
rconService.getPlayers($scope, function(players) {
for(var i in players) {
if(players[i].SteamID === $scope.userid){
// set player data
$scope.info = players[i];
// remove xp remnants
if ($scope.info['CurrentLevel'] !== undefined)
delete $scope.info['CurrentLevel'];
if ($scope.info['UnspentXp'] !== undefined)
delete $scope.info['UnspentXp'];
// fix violation level
if ($scope.info['VoiationLevel'] !== undefined){
var violationLevel = $scope.info['VoiationLevel'];
delete $scope.info['VoiationLevel'];
$scope.info['ViolationLevel'] = violationLevel;
}
return;
}
}
// player not found
// reset data to null
$scope.info = null;
});
}
$scope.getUsername = function ()
{
// try to find players name in info
if($scope.info && $scope.info.DisplayName) {
return $scope.info.DisplayName;
}
// otherwise show the id
return $scope.userid;
}
rconService.InstallService( $scope, $scope.refresh )
}
-50
View File
@@ -1,50 +0,0 @@
app.controller( 'PlayerListController', PlayerListController );
function PlayerListController( $scope, rconService, $interval )
{
$scope.Output = [];
$scope.OrderBy = '-ConnectedSeconds';
$scope.Refresh = function ()
{
rconService.getPlayers($scope, function ( players )
{
$scope.Players = players;
});
}
$scope.Order = function( field )
{
if ( $scope.OrderBy === field )
{
field = '-' + field;
}
$scope.OrderBy = field;
}
$scope.SortClass = function( field )
{
if ( $scope.OrderBy === field ) return "sorting";
if ( $scope.OrderBy === "-" + field ) return "sorting descending";
return null;
}
$scope.KickPlayer = function ( id )
{
rconService.Command( 'kick ' + id );
$scope.Refresh();
}
rconService.InstallService( $scope, $scope.Refresh )
// var timer = $interval( function ()
// {
// //$scope.Refresh();
// }.bind( this ), 1000 );
//$scope.$on( '$destroy', function () { $interval.cancel( timer ) } )
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Rust WebSocket RCON Client — pure vanilla JS, zero dependencies.
*
* Protocol:
* - Connect: ws://host:port/password
* - Outgoing: { Identifier: int, Message: string, Name: "WebRcon" }
* - Identifier > 1000 → request/response (callback tracked)
* - Identifier <= 1000 → unsolicited server push (Generic/Log/Error/Warning/Chat)
*
* Usage:
* Rcon.on('connected', () => { ... });
* Rcon.on('disconnected', (ev) => { ... });
* Rcon.on('message', (msg) => { ... });
* Rcon.connect('192.168.1.100:28016', 'mypassword');
* Rcon.request('serverinfo', (msg) => { console.log(JSON.parse(msg.Message)); });
* Rcon.command('say Hello world');
* Rcon.getPlayers((players) => { ... });
*/
var Rcon = (function () {
'use strict';
var ConnectionStatus = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
};
var socket = null;
var address = null;
var callbacks = {};
var lastIndex = 1001;
var listeners = {};
/**
* Simple pub/sub event emitter.
*/
function emit(event, data) {
var cbs = listeners[event];
if (cbs) {
for (var i = 0; i < cbs.length; i++) {
cbs[i](data);
}
}
}
/**
* Connect to a Rust server's WebSocket RCON.
* @param {string} addr - "host:port"
* @param {string} pass - RCON password
*/
function connect(addr, pass) {
disconnect();
address = addr;
socket = new WebSocket('ws://' + addr + '/' + pass);
socket.onopen = function () {
emit('connected');
};
socket.onclose = function (ev) {
emit('disconnected', ev);
};
socket.onerror = function (ev) {
emit('error', ev);
};
socket.onmessage = function (e) {
var data = JSON.parse(e.data);
// Targeted response — match to pending callback
if (data.Identifier > 1000) {
var cb = callbacks[data.Identifier];
if (cb) {
cb(data);
}
delete callbacks[data.Identifier];
return;
}
// Unsolicited server push (console, chat, etc.)
emit('message', data);
};
}
/**
* Disconnect from the server.
*/
function disconnect() {
callbacks = {};
if (socket) {
socket.close();
socket = null;
}
address = null;
}
/**
* Send a fire-and-forget command (no response expected).
* @param {string} msg - RCON command
* @param {number} [identifier] - Defaults to -1
*/
function command(msg, identifier) {
if (!socket || !isConnected()) return;
if (identifier === undefined || identifier === null) {
identifier = -1;
}
socket.send(JSON.stringify({
Identifier: identifier,
Message: msg,
Name: 'WebRcon'
}));
}
/**
* Send a command and invoke callback with the response.
* @param {string} msg - RCON command
* @param {function} cb - callback(response)
*/
function request(msg, cb) {
lastIndex++;
callbacks[lastIndex] = cb;
command(msg, lastIndex);
}
/**
* Returns true if the WebSocket is open.
*/
function isConnected() {
return socket !== null && socket.readyState === ConnectionStatus.OPEN;
}
/**
* Get the current connected address (host:port).
*/
function getAddress() {
return address;
}
/**
* Convenience: fetch the player list.
* @param {function} cb - callback(playersArray)
*/
function getPlayers(cb) {
request('playerlist', function (response) {
cb(JSON.parse(response.Message));
});
}
/**
* Register an event listener.
* Events: 'connected', 'disconnected', 'error', 'message'
*/
function on(event, cb) {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(cb);
}
/**
* Remove an event listener.
*/
function off(event, cb) {
var cbs = listeners[event];
if (cbs) {
listeners[event] = cbs.filter(function (f) { return f !== cb; });
}
}
return {
connect: connect,
disconnect: disconnect,
command: command,
request: request,
isConnected: isConnected,
getAddress: getAddress,
getPlayers: getPlayers,
on: on,
off: off
};
})();
-131
View File
@@ -1,131 +0,0 @@
function RconService() {
var ConnectionStatus = {
'CONNECTING': 0,
'OPEN': 1,
'CLOSING': 2,
'CLOSED': 3
};
var Service = {
Socket: null,
Address: null,
Callbacks: {}
};
var LastIndex = 1001;
Service.Connect = function(addr, pass) {
this.Socket = new WebSocket("ws://" + addr + "/" + pass);
this.Address = addr;
this.Socket.onmessage = function(e) {
var data = angular.fromJson(e.data);
//
// This is a targetted message, it has an identifier
// So feed it back to the right callback.
//
if (data.Identifier > 1000) {
var cb = Service.Callbacks[data.Identifier];
if (cb != null) {
cb.scope.$apply(function() {
cb.callback(data);
});
}
Service.Callbacks[data.Identifier] = null;
return;
}
//
// Generic console message, let OnMessage catch it
//
if (Service.OnMessage != null) {
Service.OnMessage(data);
}
};
this.Socket.onopen = this.OnOpen;
this.Socket.onclose = this.OnClose;
this.Socket.onerror = this.OnError;
}
Service.Disconnect = function() {
if (this.Socket) {
this.Socket.close();
this.Socket = null;
}
this.Callbacks = {};
}
Service.Command = function(msg, identifier) {
if (this.Socket === null)
return;
if (!this.IsConnected())
return;
if (identifier === null)
identifier = -1;
var packet = {
Identifier: identifier,
Message: msg,
Name: "WebRcon"
};
this.Socket.send(JSON.stringify(packet));
};
//
// Make a request, call this function when it returns
//
Service.Request = function(msg, scope, callback) {
LastIndex++;
this.Callbacks[LastIndex] = {
scope: scope,
callback: callback
};
Service.Command(msg, LastIndex);
}
//
// Returns true if websocket is connected
//
Service.IsConnected = function() {
if (this.Socket == null)
return false;
return this.Socket.readyState === ConnectionStatus.OPEN;
}
//
// Helper for installing connectivity logic
//
// Basically if not connected, call this function when we are
// And if we are - then call it right now.
//
Service.InstallService = function(scope, func) {
scope.$on("OnConnected", function() {
func();
});
if (this.IsConnected()) {
func();
}
}
Service.getPlayers = function(scope, success) {
this.Request("playerlist", scope, function(response) {
var players = JSON.parse(response.Message);
if (typeof success === 'function') {
success.call(scope, players);
}
});
}
return Service;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Minimal hash-based SPA router.
*
* Route format: { pattern: RegExp, title: string, template: string, onEnter: function(params, container) }
*
* The first matching route's onEnter is called with parsed params and a container element.
* The container is cleared before each navigation.
*
* Usage:
* Router.add(/^\/(.+)\/info$/, 'Server', 'html/serverInfo.html', ServerInfoPage);
* Router.add(/^\/(.+)\/player\/(.+)$/, 'Player Info', 'html/playerInfo.html', PlayerInfoPage);
* Router.start('#app-view');
*/
var Router = (function () {
'use strict';
var routes = [];
var viewSelector = null;
var currentCleanup = null;
function add(pattern, title, template, onEnter) {
routes.push({ pattern: pattern, title: title, template: template, onEnter: onEnter });
}
function navigate(hash) {
// strip leading # and optional /
var path = (hash || '').replace(/^#\/?/, '/');
if (!path || path === '/') path = '/home';
for (var i = 0; i < routes.length; i++) {
var r = routes[i];
var match = path.match(r.pattern);
if (match) {
// Store params for later use
var container = document.querySelector(viewSelector);
if (!container) return;
// Cleanup previous page
if (currentCleanup && typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
// Clear container
container.innerHTML = '';
// Call onEnter with match groups + container
if (typeof r.onEnter === 'function') {
var result = r.onEnter(match, container);
if (typeof result === 'function') {
currentCleanup = result;
}
}
return;
}
}
}
function start(selector) {
viewSelector = selector;
window.addEventListener('hashchange', function () {
navigate(window.location.hash);
});
// Initial navigation
if (window.location.hash) {
navigate(window.location.hash);
}
}
function go(hash) {
window.location.hash = hash;
}
return {
add: add,
start: start,
go: go,
navigate: navigate
};
})();
-231
View File
@@ -1,231 +0,0 @@
app.controller('ServerInfoController', ServerInfoController);
var DATA_LIMIT = 100;
var recordedData = [];
function ServerInfoController($scope, rconService, $routeParams, $interval) {
$scope.useCharts = false;
// TODO: move serverinfo to service
$scope.serverinfo = {};
$scope.playersChart = {
options: {
chart: {
type: 'pieChart',
height: 250,
margin: {
top: 0,
right: 75,
bottom: 0,
left: 75
},
donut: true,
x: function(d) {
return d.key;
},
y: function(d) {
return d.y;
},
yAxis: {
axisLabel: 'Slots',
tickFormat: function(d) {
return d3.format('.d')(d);
}
},
showLabels: true,
pie: {
startAngle: function(d) {
return d.startAngle / 2 - Math.PI / 2
},
endAngle: function(d) {
return d.endAngle / 2 - Math.PI / 2
}
},
duration: 500
}
},
data: []
};
$scope.performanceChart = {
options: {
chart: {
type: 'multiChart',
height: 200,
margin: {
top: 30,
right: 75,
bottom: 40,
left: 75
},
color: [
'darkred', 'yellowgreen'
],
duration: 0,
lines1: {
duration: 0
},
lines2: {
duration: 0
},
useInteractiveGuideline: true,
yAxis1: {
axisLabel: 'Framerate',
tickFormat: function(d) {
return d3.format(',d')(d);
}
},
yAxis2: {
axisLabel: 'Entities',
tickFormat: function(d) {
return d3.format(',d')(d);
},
// axisLabelDistance: 12
},
xAxis: {
axisLabel: "Time",
tickFormat: function(d) {
return d3.time.format('%H:%M:%S')(new Date(d))
}
}
}
},
data: [
{
key: 'Framerate',
type: "line",
duration: 0,
yAxis: 1,
values: []
}, {
key: 'Entities',
type: 'line',
duration: 0,
yAxis: 2,
values: []
}
]
};
$scope.netChart = {
options: {
chart: {
type: 'stackedAreaChart',
height: 250,
margin: {
top: 0,
right: 75,
bottom: 40,
left: 75
},
useVoronoi: false,
duration: 0,
useInteractiveGuideline: true,
yAxis: {
axisLabel: 'Network',
tickFormat: function(bytes) {
var fmt = d3.format('.0f');
if (bytes < 1024) {
return fmt(bytes) + 'B';
} else if (bytes < 1024 * 1024) {
return fmt(bytes / 1024) + 'kB';
} else if (bytes < 1024 * 1024 * 1024) {
return fmt(bytes / 1024 / 1024) + 'MB';
} else {
return fmt(bytes / 1024 / 1024 / 1024) + 'GB';
}
}
},
xAxis: {
axisLabel: "Time",
tickFormat: function(d) {
return d3.time.format('%H:%M:%S')(new Date(d))
}
}
}
},
data: [
{
key: 'IN',
values: []
}, {
key: 'OUT',
values: []
}
]
};
rconService.InstallService($scope, _refresh);
// TODO: move updateinterval to service
var timer = $interval(_refresh, 1000);
$scope.$on("$destroy", function() {
$interval.cancel(timer);
$scope.serverinfo = {};
});
function _refresh() {
rconService.Request('serverinfo', $scope, function(msg) {
_updateData(JSON.parse(msg.Message));
});
}
function _updateData(data) {
$scope.serverinfo = data;
if ($scope.useCharts) {
_collectChartData(data);
_generateChartData();
}
}
function _collectChartData(data) {
// player chart
$scope.playersChart.data = [
{
key: 'Queued',
y: data.Queued
}, {
key: 'Joining',
y: data.Joining
}, {
key: 'Players',
y: data.Players
}, {
key: 'Free',
y: (data.MaxPlayers - (data.Joining + data.Players))
}
];
recordedData.push({ts: Date.now(), data: data});
if (recordedData.length > DATA_LIMIT) {
recordedData = recordedData.slice(Math.max(recordedData.length - DATA_LIMIT, 1));
}
}
function _generateChartData() {
var fpsChartValues = [];
var entChartValues = [];
var netInChartValues = [];
var netOutChartValues = [];
for (var i = 0; i < recordedData.length; i++) {
var record = recordedData[i];
fpsChartValues.push({x: record.ts, y: record.data.Framerate});
entChartValues.push({x: record.ts, y: record.data.EntityCount});
netInChartValues.push({x: record.ts, y: record.data.NetworkIn});
netOutChartValues.push({x: record.ts, y: record.data.NetworkOut});
}
$scope.performanceChart.data[0].values = fpsChartValues;
$scope.performanceChart.data[1].values = entChartValues;
$scope.netChart.data[0].values = netInChartValues;
$scope.netChart.data[1].values = netOutChartValues;
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Utility functions shared across pages.
*/
/**
* Convert seconds to a human-readable duration string.
* e.g., 3723 → "1h2m3s"
*/
function secondsToDuration(input) {
input = parseInt(input, 10);
if (isNaN(input)) return '0s';
var out = '';
var hours = Math.floor(input / 3600);
if (hours > 0) out += hours + 'h';
var minutes = Math.floor((input % 3600) / 60);
if (minutes > 0) out += minutes + 'm';
var seconds = input % 60;
out += seconds + 's';
return out;
}
/**
* Strip HTML tags from a string.
*/
function stripHtml(text) {
return text ? String(text).replace(/<[^>]+>/gm, '') : '';
}
/**
* Format a number with locale-aware separators (fallback).
*/
function formatNumber(n) {
n = parseInt(n, 10);
if (isNaN(n)) return '0';
return n.toLocaleString ? n.toLocaleString() : String(n);
}
/**
* Check if user is scrolled to the bottom of an element.
*/
function isScrolledToBottom(el) {
return (el.scrollTop + el.clientHeight) >= (el.scrollHeight - 10);
}