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
-17
View File
@@ -1,17 +0,0 @@
<div ng-controller="ChatController" id="ChatController">
<div style="height: 600px" class="Output scrollable">
<div ng-repeat="line in Output" ng-class="line.Class">
<span style="min-width: 60px; display:inline-block;">{{(line.Time * 1000) | date:'shortTime'}}</span>
<span style="min-width: 130px; display:inline-block; text-align: right;"><a href="#/{{address}}/player/{{line.UserId}}/">{{line.Username}} :</a></span>
<span style="display:inline-block;">{{line.Message}}</span>
</div>
</div>
<form ng-submit="SubmitCommand()" class="md-inline-form">
<div>
<input class="form-control" ng-model="Command" style="width: 100%">
</div>
</form>
</div>
-54
View File
@@ -1,54 +0,0 @@
<div ng-controller="ConnectionController" class="row">
<div class="col-md-offset-4 col-md-6 panel jumbotron" style="margin-top: 128px">
<h2>Connect</h2>
<div class="alert alert-danger" ng-show="LastErrorMessage != null">
{{LastErrorMessage}}
</div>
<p>Enter the address (including rcon port) and the rcon password below to connect.</p>
<form ng-submit="Connect()">
<div class="form-group">
<label for="conncetion-adress">Address</label>
<input required minlength=5 ng-model="Address" name="Address" class="form-control" placeholder="xxx.xxx.xxx.xxx:28016" id="conncetion-adress">
</div>
<div class="form-group">
<label for="conncetion-password">Password</label>
<input required minlength=2 type="password" ng-model="Password" name="Password" class="form-control" id="conncetion-password">
</div>
<div class="checkbox pull-right">
<label>
<input type="checkbox" ng-model="SaveConnection"> Save Connection
</label>
</div>
<button type="submit" class="btn btn-primary btn-block" ng-click="Connect()">Connect</button>
</form>
</div>
<div class="col-md-offset-4 col-md-6 panel jumbotron">
<div ng-show="PreviousConnects.length > 0">
<h2>Saved Connections</h2>
<ul ng-repeat="c in PreviousConnects | orderBy:array:true | limitTo:connectionsLimit">
<li>
<a ng-click="ConnectTo( c )" href="#/{{c.Address}}/info">{{c.Address}}</a> - {{c.date | date:'short'}}
</li>
</ul>
<div ng-show="PreviousConnects.length > connectionsLimit || connectionsLimit === undefined">
<button type="submit" class="btn btn-primary btn-block" ng-click="toggleConnectionsLimit()">
Show {{(connectionsLimit === undefined) ? 'less' : 'more'}}
</button>
</div>
</div>
</div>
</div>
-11
View File
@@ -1,11 +0,0 @@
<div ng-controller="ConsoleController" id="ConsoleController">
<div style="height: 600px" class="Output scrollable">
<div ng-repeat="line in Output" ng-class="line.Class">{{line.Message}}</div>
</div>
<form ng-submit="SubmitCommand()">
<input type="text" class="form-control" ng-model="Command" ng-keyup="KeyUp($event)" />
</form>
</div>
-40
View File
@@ -1,40 +0,0 @@
<nav class="navbar navbar-default" ng-controller="ServerInfoController" ng-show="serverinfo">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse-header" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">{{serverinfo.Hostname}}</a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse-header">
<p class="navbar-text" title="Uptime">
<span class="glyphicon glyphicon-time"></span>
{{serverinfo.Uptime|SecondsToDuration}}
</p>
<p class="navbar-text" title="Map Name">
<span class="glyphicon glyphicon-globe"></span>
{{serverinfo.Map}}
</p>
<p class="navbar-text" title="Current Players/Maximum Players">
<span class="glyphicon glyphicon-user"></span>
{{serverinfo.Players|number}}/{{serverinfo.MaxPlayers|number}}
</p>
<p class="navbar-text" title="Framerate">
<span class="glyphicon glyphicon-hourglass"></span>
{{serverinfo.Framerate|number}}fps
</p>
<p class="navbar-text" title="Entity Count">
<span class="glyphicon glyphicon-oil"></span>
{{serverinfo.EntityCount|number}}
</p>
<button class="btn btn-danger navbar-btn navbar-right" type="button" ng-click="Disconnect()">Disconnect</button>
</div>
</div>
</nav>
-41
View File
@@ -1,41 +0,0 @@
<div ng-controller="PlayerInfoController" id="PlayerInfoController">
<div class="row">
<div class="col-lg-12">
<h2 class="center">
Player: {{getUsername()}}
<div class="btn-group pull-right">
<button ng-click="refresh()" class="btn btn-default" type="button"><i class="glyphicon glyphicon-refresh"></i></button>
</div>
</h2>
<div class="btn-group btn-group-lg btn-group-justified">
<div class="btn-group">
<a href="http://www.steamcommunity.com/profiles/{{userid}}" target="_blank" class="btn btn-default">
<span class="glyphicon glyphicon-info-sign"></span>
Steam Profile
</a>
</div>
</div>
</div>
<div class="col-lg-12">
<h2 class="center">
Info
</h2>
<div class="alert alert-warning" ng-show="!info">
This Player is currently not connected to this server!
</div>
</div>
<div class="col-sm-4" ng-repeat="(key, value) in info">
<p>{{key}}: <span class="badge">{{value}}</span></p>
</div>
</div>
</div>
-84
View File
@@ -1,84 +0,0 @@
<div ng-controller="PlayerListController" id="PlayerListController">
<div class="row">
<div class="col-xs-6">
<input ng-model="FilterText" class="form-control" placeholder="Filter..">
</div>
<div class="col-xs-6">
<button ng-click="Refresh()" class="btn btn-default pull-right" type="button"><span class="glyphicon glyphicon-refresh"></span></button>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Actions</th>
<th ng-click="Order( 'DisplayName' )" ng-class="SortClass( 'DisplayName' )">Username</th>
<th class="text-right" ng-click="Order( 'SteamID' )" ng-class="SortClass( 'SteamID' )">Steam ID</th>
<th class="text-right" ng-click="Order( 'VoiationLevel' )" ng-class="SortClass( 'VoiationLevel' )">Violation Level</th>
<th class="text-right" ng-click="Order( 'Address' )" ng-class="SortClass( 'Address' )">Address</th>
<th class="text-right" ng-click="Order( 'Ping' )" ng-class="SortClass( 'Ping' )">Ping</th>
<th class="text-right" ng-click="Order( 'ConnectedSeconds' )" ng-class="SortClass( 'ConnectedSeconds' )">Duration</th>
</tr>
</thead>
<tr ng-repeat="line in Players | orderBy: OrderBy | filter:FilterText" href="#/{{address}}/player/{{line.SteamID}}/">
<td>
<div class="btn-group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-cog"></span> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li>
<a href="http://www.steamcommunity.com/profiles/{{line.SteamID}}" target="_blank">
<span class="glyphicon glyphicon-info-sign"></span>
Steam Profile
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="KickPlayer(line.SteamID)">
<span class="glyphicon glyphicon-trash"></span>
Kick
</a>
</li>
</ul>
</div>
</td>
<td>
<a href="#/{{address}}/player/{{line.SteamID}}/">{{line.DisplayName}}</a>
</td>
<td style="text-align: right">
<a href="#/{{address}}/player/{{line.SteamID}}/">{{line.SteamID}}</a>
</td>
<td class="text-right" ng-class="{fade: line.VoiationLevel == 0 }">
{{line.VoiationLevel}}
</td>
<td class="text-right">
{{line.Address}}
</td>
<td class="text-right">
{{line.Ping}}
</td>
<td class="text-right">
{{line.ConnectedSeconds | SecondsToDuration}}
</td>
<md-divider></md-divider>
</tr>
</table>
</div>
</div>
</div>
</div>
-92
View File
@@ -1,92 +0,0 @@
<div ng-controller="ServerInfoController" id="ServerInfoController" data-ng-init="useCharts=true;">
<div class="row">
<div class="col-sm-12">
<h2>{{serverinfo.Hostname}}</h2>
</div>
<div class="col-sm-4" ng-repeat="(key, value) in serverinfo">
<p>
<span class="text-muted">{{key}}:</span>
<span>{{value}}</span>
</p>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="panel panel-default">
<div class="panel-heading" id="headingPerformance">
<button
class="btn btn-xs btn-primary pull-right"
role="button"
data-toggle="collapse"
data-target="#performanceCollapse"
aria-expanded="true"
aria-controls="performanceCollapse">
toggle
</button>
<h4 class="panel-title">
Server Performance
</h4>
</div>
<div id="performanceCollapse" class="panel-collapse collapse in" aria-labelledby="headingPerformance">
<div class="panel-body">
<nvd3 options="performanceChart.options" data="performanceChart.data"></nvd3>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading" id="headingNetwork">
<button
class="btn btn-xs btn-primary pull-right"
role="button"
data-toggle="collapse"
data-target="#networkCollapse"
aria-expanded="true"
aria-controls="networkCollapse">
toggle
</button>
<h4 class="panel-title">
Server Networking
</h4>
</div>
<div id="networkCollapse" class="panel-collapse collapse in" aria-labelledby="headingNetwork">
<div class="panel-body">
<nvd3 options="netChart.options" data="netChart.data"></nvd3>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading" id="headingPlayers">
<button
class="btn btn-xs btn-primary pull-right"
role="button"
data-toggle="collapse"
data-target="#playersCollapse"
aria-expanded="true"
aria-controls="playersCollapse">
toggle
</button>
<h4 class="panel-title">
Players
</h4>
</div>
<div id="playersCollapse" class="panel-collapse collapse in" aria-labelledby="headingPlayers">
<div class="panel-body">
<nvd3 options="playersChart.options" data="playersChart.data"></nvd3>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17,12L12,17V14H8V10H12V7L17,12M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L5,8.09V15.91L12,19.85L19,15.91V8.09L12,4.15Z" /></svg>

Before

Width:  |  Height:  |  Size: 667 B

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z" /></svg>

Before

Width:  |  Height:  |  Size: 341 B

+48 -51
View File
@@ -1,71 +1,68 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" ng-app="RconApp"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Websocket Rcon</title> <title>Websocket Rcon</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.2/nv.d3.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.2/nv.d3.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/css/bootstrap.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/css/bootstrap-theme.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="rcon.css" rel="stylesheet"> <link href="rcon.css" rel="stylesheet">
</head> </head>
<body ng-controller="RconController"> <body class="bg-dark text-light">
<div ng-include="'html/header.html'" ng-show="Connected"></div>
<!-- Server info header (hidden until connected) -->
<nav class="navbar navbar-dark bg-primary" id="server-header" style="display:none">
<div class="container"> <div class="container">
<a class="navbar-brand" href="#" id="header-hostname"></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapse-header">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-collapse-header">
<span class="navbar-text me-3" title="Uptime" id="header-uptime"></span>
<span class="navbar-text me-3" title="Map Name" id="header-map"></span>
<span class="navbar-text me-3" title="Players" id="header-players"></span>
<span class="navbar-text me-3" title="Framerate" id="header-fps"></span>
<span class="navbar-text me-3" title="Entity Count" id="header-entities"></span>
<button class="btn btn-danger ms-auto" type="button" id="btn-disconnect">Disconnect</button>
</div>
</div>
</nav>
<div ng-include="'html/connect.html'" ng-show="!Connected"></div> <!-- Main layout: hidden until connected -->
<div id="app-connected" style="display:none">
<div ng-show="Connected" class="col-sm-2 sidebar"> <div class="container">
<div class="row">
<ul class="nav nav-sidebar"> <div class="col-md-2">
<li ng-repeat="page in pages" ng-class="{'active' : $route.current.Title == page.Title }"> <ul class="nav flex-column nav-sidebar" id="sidebar-nav"></ul>
<a href="#{{Nav( page.originalPath )}}">{{page.Title}}</a> </div>
</li> <div class="col-md-10">
</ul> <h1 id="page-title"></h1>
<div id="app-view"></div>
</div>
</div>
</div>
</div> </div>
<div class="col-sm-10" ng-show="Connected"> <!-- Connect page: shown when not connected -->
<div id="app-connect" class="container"></div>
<h1 class="page-header">{{$route.current.Title}}</h1> <!-- Bootstrap 5 JS (vanilla, no jQuery needed) -->
<div class="row" ng-view></div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Charts -->
</div>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<!-- AngularJS -->
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-route.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-animate.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-aria.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-messages.min.js"></script>
<!-- Bootstrap -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/js/bootstrap.js"></script>
<!-- For charts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.16/d3.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.16/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.1/nv.d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.1/nv.d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular-nvd3/1.0.9/angular-nvd3.min.js"></script>
<!-- Data manipulation --> <!-- App scripts -->
<script src="js/linq.min.js"></script> <script src="js/utils.js"></script>
<script src="js/rcon.js"></script>
<!-- Our stuff --> <script src="js/router.js"></script>
<script src="js/rconService.js"></script> <script src="js/pages/connect.js"></script>
<script src="js/pages/serverInfo.js"></script>
<script src="js/pages/console.js"></script>
<script src="js/pages/chat.js"></script>
<script src="js/pages/playerlist.js"></script>
<script src="js/pages/playerInfo.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/connection.js"></script>
<script src="js/serverInfo.js"></script>
<script src="js/console.js"></script>
<script src="js/playerlist.js"></script>
<script src="js/playerInfo.js"></script>
<script src="js/chat.js"></script>
</body> </body>
</html> </html>
+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) { // ---- Sidebar navigation ----
$routeProvider.when("/home", {Title: "Home"}); var navPages = [
$routeProvider.when("/:address/info", { { title: 'Server', path: '/:address/info' },
Title: "Server", { title: 'Console', path: '/:address/console' },
templateUrl: "html/serverInfo.html", { title: 'Chat', path: '/:address/chat' },
Nav: true { title: 'Player List', path: '/:address/playerlist' }
}); ];
$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'});
});
app.controller('RconController', RconController); function renderSidebar(address) {
var html = '';
function RconController($scope, $rootScope, rconService, $timeout, $route) { for (var i = 0; i < navPages.length; i++) {
$scope.$route = $route; var url = navPages[i].path.replace(':address', address);
var linkCls = (window._currentPage === navPages[i].title) ? ' nav-link active' : 'nav-link';
$scope.pages = $.map($route.routes, function(value, index) { html += '<li class="nav-item"><a class="' + linkCls + '" href="#' + url + '">' + navPages[i].title + '</a></li>';
if (value.Nav) {
return [value];
} }
}); elSidebar.innerHTML = html;
$scope.OpenLeftMenu = function() {
$mdSidenav('left').toggle();
};
$scope.IsConnected = function() {
return rconService.IsConnected();
} }
$rootScope.Nav = function(url) { function setActivePage(title) {
return url.replace(":address", rconService.Address); window._currentPage = title;
elTitle.textContent = title;
if (Rcon.isConnected()) renderSidebar(Rcon.getAddress());
} }
$rootScope.$on('$stateChangeStart', function(next, current) { // ---- Header update ----
console.log(next); var headerTimer = null;
});
rconService.OnOpen = function() { function updateHeader() {
$scope.Connected = true; Rcon.request('serverinfo', function (msg) {
$scope.$broadcast("OnConnected"); var data = JSON.parse(msg.Message);
$scope.$digest(); document.getElementById('header-hostname').textContent = data.Hostname || 'Rust Server';
$scope.address = rconService.Address; 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);
rconService.OnClose = function(ev) { document.getElementById('header-fps').innerHTML = '<i class="bi bi-speedometer2"></i> ' + formatNumber(data.Framerate) + 'fps';
$scope.$broadcast("OnDisconnected", ev); document.getElementById('header-entities').innerHTML = '<i class="bi bi-boxes"></i> ' + formatNumber(data.EntityCount);
$scope.$digest();
}
rconService.OnError = function(ev) {
$scope.$broadcast("OnConnectionError", ev);
$scope.$digest();
}
rconService.OnMessage = function(msg) {
$scope.$apply(function() {
$scope.$broadcast("OnMessage", msg);
}); });
} }
$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?')) { if (confirm('Do you really want to disconnect?')) {
rconService.Disconnect(); Rcon.disconnect();
$scope.Connected = false; }
});
$scope.address = '#/home'; // ---- 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 showDisconnected() {
stopHeaderUpdates();
function SecondsToDuration() { elConnected.style.display = 'none';
return function(input) { elHeader.style.display = 'none';
input = parseInt(input); elConnect.style.display = 'block';
// Clear any stale router content
var out = ""; document.getElementById('app-view').innerHTML = '';
var hours = Math.floor(input / 3600); ConnectPage([], elConnect);
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;
}
} }
// ---- 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);
}
+24
View File
@@ -0,0 +1,24 @@
# Example nginx config for WebRcon
# Copy the project files to /var/www/webrcon (or wherever you like)
# and symlink this file into /etc/nginx/sites-enabled/
server {
listen 80;
server_name rcon.yourdomain.com;
root /var/www/webrcon;
index index.html;
# SPA routing — all paths serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively (all deps are CDN-loaded anyway)
location ~* \.(css|js|svg|png|jpg|jpeg|gif|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# No backend — browser talks WebSocket directly to the game server
}
-250
View File
@@ -1,250 +0,0 @@
{
"name": "webrcon",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "webrcon",
"devDependencies": {
"http-server": "^0.9.0"
}
},
"node_modules/async": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz",
"integrity": "sha512-XQJ3MipmCHAIBBMFfu2jaSetneOrXbSyyqeU3Nod867oNOpS+i9FEms5PWgjMxSgBybRf2IVVLtr1YfrDO+okg==",
"dev": true
},
"node_modules/colors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz",
"integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/corser": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
"integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/ecstatic": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-1.4.1.tgz",
"integrity": "sha512-VC4nmLt4/3KmKbSXxREmJMs+iAB+VdOzMyugV4YzG6d/2NcHa+b0J5/zKGRWS7efwNLEZP2AIodUUm12wOglng==",
"deprecated": "This package is unmaintained and deprecated. See the GH Issue 259.",
"dev": true,
"license": "MIT",
"dependencies": {
"he": "^0.5.0",
"mime": "^1.2.11",
"minimist": "^1.1.0",
"url-join": "^1.0.0"
},
"bin": {
"ecstatic": "lib/ecstatic.js"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"dev": true,
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/he": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz",
"integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-server": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/http-server/-/http-server-0.9.0.tgz",
"integrity": "sha512-ufsBg3xl4RRokW37Kg1uGS4MKkpfXqRrtuKYDT9QaBxqix9IHZ2QbOk2RDTOM8MJjWRzjozCC5heZX/azjKB6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"colors": "1.0.3",
"corser": "~2.0.0",
"ecstatic": "^1.4.0",
"http-proxy": "^1.8.1",
"opener": "~1.4.0",
"optimist": "0.6.x",
"portfinder": "0.4.x",
"union": "~0.4.3"
},
"bin": {
"hs": "bin/http-server",
"http-server": "bin/http-server"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/opener": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz",
"integrity": "sha512-4Im9TrPJcjAYyGR5gBe3yZnBzw5n3Bfh1ceHHGNOpMurINKc6RdSIPXMyon4BZacJbJc36lLkhipioGbWh5pwg==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "opener.js"
}
},
"node_modules/optimist": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
"integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==",
"dev": true,
"license": "MIT/X11",
"dependencies": {
"minimist": "~0.0.1",
"wordwrap": "~0.0.2"
}
},
"node_modules/optimist/node_modules/minimist": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
"integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==",
"dev": true,
"license": "MIT"
},
"node_modules/portfinder": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-0.4.0.tgz",
"integrity": "sha512-SZ3hp61WVhwNSS0gf0Fdrx5Yb/wl35qisHuPVM1S0StV8t5XlVZmmJy7/417OELJA7t6ecEmeEzvOaBwq3kCiQ==",
"dev": true,
"license": "MIT/X11",
"dependencies": {
"async": "0.9.0",
"mkdirp": "0.5.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/qs": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-2.3.3.tgz",
"integrity": "sha512-f5M0HQqZWkzU8GELTY8LyMrGkr3bPjKoFtTkwUEqJQbcljbeK8M7mliP9Ia2xoOI6oMerp+QPS7oYJtpGmWe/A==",
"dev": true
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/union": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/union/-/union-0.4.6.tgz",
"integrity": "sha512-2qtrvSgD0GKotLRCNYkIMUOzoaHjXKCtbAP0kc5Po6D+RWTBb+BxlcHlHCYcf+Y+YM7eQicPgAg9mnWQvtoFVA==",
"dev": true,
"dependencies": {
"qs": "~2.3.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/url-join": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-1.1.0.tgz",
"integrity": "sha512-zz1wZk4Lb5PTVwZ3HWDmm8XnlPvmOof6/fjdDPA5yBrUcbtV64U6bV832Zf1BtU2WkBBWaUT46wCs+l0HP5nhg==",
"dev": true,
"license": "MIT"
},
"node_modules/wordwrap": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
"integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "webrcon",
"description": "Game server Rcon, using websockets",
"repository": {
"type": "git",
"url": "https://github.com/Facepunch/webrcon"
},
"scripts": {
"server": "http-server -p 8888",
"start": "npm run server"
},
"devDependencies": {
"http-server": "^0.9.0"
}
}
+77 -104
View File
@@ -1,151 +1,124 @@
@import url(https://fonts.googleapis.com/css?family=Roboto:400,700); @import url(https://fonts.googleapis.com/css?family=Roboto:400,700);
body :root {
{ --rcon-bg: #252830;
--rcon-text: #cfd2da;
--rcon-accent: #1ca8dd;
--rcon-card-bg: #434857;
}
body {
font-family: "Roboto", sans-serif; font-family: "Roboto", sans-serif;
font-size: 14px; font-size: 14px;
font-weight: 300; font-weight: 300;
line-height: 1.5;
color: #cfd2da;
background-color: #252830;
} }
a a { color: var(--rcon-accent); }
{ a:hover { color: #fff; text-decoration: none; }
color: #1ca8dd;
}
a:hover /* Console output */
{ #ConsoleController .Output {
color: #fff;
text-decoration: none;
}
.center
{
text-align: center;
}
#ConsoleController .Output
{
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
white-space: pre-wrap; white-space: pre-wrap;
} }
.scrollable .scrollable { overflow: auto; }
{
overflow: auto;
}
/* Sidebar navigation */
.nav-sidebar .nav-sidebar {
{
margin-top: 64px; margin-top: 64px;
margin-right: 16px;
} }
.nav-sidebar > li > a .nav-sidebar .nav-link {
{ padding: 8px 20px;
padding-right: 20px;
padding-left: 20px;
border-radius: 5px; border-radius: 5px;
color: var(--rcon-text);
} }
.nav-sidebar > .active > a, .nav-sidebar .nav-link:hover,
.nav-sidebar > .active > a:hover, .nav-sidebar .nav-link.active {
.nav-sidebar > .active > a:focus
{
color: #fff; color: #fff;
background-color: #428bca; background-color: #428bca;
} }
.fade /* Fade utility */
{ .fade { opacity: 0.5; }
opacity: 0.5;
/* Table styles */
.table > tbody > tr:hover {
background-color: rgba(255, 255, 255, 0.15);
} }
.table > tbody > tr:hover .table > thead > tr > th.sorting {
{
background-color: rgba( 255, 255, 255, 0.3 );
}
.table > thead > tr > th.sorting
{
color: #1bc98e; color: #1bc98e;
border-color: #1bc98e; border-color: #1bc98e;
} }
.table > thead > tr > th.sorting.descending {
color: #e05d6f;
border-color: #e05d6f;
}
.table-responsive { .table-responsive {
overflow-x: visible !important; overflow-x: visible !important;
overflow-y: visible !important; overflow-y: visible !important;
} }
.btn-default, .btn-default:active, .form-control /* Form controls dark theme */
{ .form-control {
background: rgba( 255, 255, 255, 0.3 ); background: rgba(255, 255, 255, 0.15);
border: none; border: 1px solid rgba(255, 255, 255, 0.2);
color: #cfd2da; color: var(--rcon-text);
text-shadow: none;
} }
.btn-default:hover .form-control:focus {
{ background: rgba(255, 255, 255, 0.2);
background: #1ca8dd; color: #fff;
border-color: var(--rcon-accent);
box-shadow: 0 0 0 0.2rem rgba(28, 168, 221, 0.25);
}
/* Card dark theme */
.card {
background-color: var(--rcon-card-bg);
border-color: rgba(255, 255, 255, 0.1);
}
/* Dropdown dark theme */
.dropdown-menu {
background-color: var(--rcon-card-bg);
border-color: rgba(255, 255, 255, 0.1);
}
.dropdown-menu .dropdown-item {
color: var(--rcon-text);
}
.dropdown-menu .dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.15);
color: #fff; color: #fff;
} }
.panel .dropdown-menu .dropdown-divider {
{ border-color: rgba(255, 255, 255, 0.15);
background-color: #434857;
} }
.chatname /* Navbar text color override for dark theme */
{ .navbar-dark .navbar-text {
display: block; color: rgba(255, 255, 255, 0.85);
width: 200px;
float: left;
text-align: right;
padding-right: 8px;
} }
.navbar-default /* Page title */
{ #page-title {
background-color: #428bca; padding-bottom: 0.5rem;
background-image: none; margin-bottom: 1rem;
border: none; border-bottom: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0;
color: white;
} }
.navbar-default .navbar-brand, .navbar-default .navbar-brand:hover /* Chart panels */
{ #chart-performance,
color: #fff; #chart-network,
} #chart-players {
min-height: 200px;
.navbar-default .navbar-text,
.navbar-default .navbar-nav > li > a, .navbar-default .navbar-nav > li > a:hover, .navbar-default .navbar-nav > li > a:focus
{
color: #fff;
}
.navbar-default .navbar-nav > li > a:hover
{
background-color: rgba( 255, 255, 255, 0.25 );
}
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .active > a
{
background-color: white;
background-image: none;
border: none;
color: #428bca;
}
.navbar-info
{
position: relative;
display: block;
padding: 15px;
line-height: 20px;
} }