Merge branch 'websocket' into 'dev'

WebSockets

See merge request crafty-controller/crafty-commander!25
This commit is contained in:
Phillip Tarrant 2021-03-07 21:30:07 +00:00
commit 33036eed92
9 changed files with 353 additions and 10 deletions

View File

@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
from app.classes.shared.console import console
from app.classes.shared.helpers import helper
from app.classes.shared.tasks import tasks_manager
from app.classes.web.websocket_helper import websocket_helper
try:
import requests
@ -43,7 +44,9 @@ class MainPrompt(cmd.Cmd):
def do_exit(self, line):
logger.info("Stopping all server daemons / threads")
console.info("Stopping all server daemons / threads - This may take a few seconds")
websocket_helper.disconnect_all()
self._clean_shutdown()
console.info('Waiting for main thread to stop')
while True:
if tasks_manager.get_main_thread_run_status():
sys.exit(0)

View File

@ -6,6 +6,7 @@ import datetime
from app.classes.shared.helpers import helper
from app.classes.shared.console import console
from app.classes.minecraft.server_props import ServerProps
from app.classes.web.websocket_helper import websocket_helper
logger = logging.getLogger(__name__)
@ -490,7 +491,7 @@ class db_shortcuts:
@staticmethod
def get_server_friendly_name(server_id):
server_data = db_helper.get_server_data_by_id(server_id)
friendly_name = "{}-{}".format(server_data.get('server_id', 0), server_data.get('server_name', None))
friendly_name = "{} with ID: {}".format(server_data.get('server_name', None), server_data.get('server_id', 0))
return friendly_name
@staticmethod
@ -498,7 +499,8 @@ class db_shortcuts:
server_name = db_helper.get_server_friendly_name(server_id)
db_helper.add_to_audit_log(user_id, "Issued Command {} for Server: {}".format(command, server_name),
# Example: Admin issued command start_server for server Survival
db_helper.add_to_audit_log(user_id, "issued command {} for server {}".format(command, server_name),
server_id, remote_ip)
Commands.insert({
@ -533,6 +535,8 @@ class db_shortcuts:
audit_msg = "{} {}".format(str(user_data.username).capitalize(), log_msg)
websocket_helper.broadcast('notification', audit_msg)
Audit_Log.insert({
Audit_Log.user_name: user_data.username,
Audit_Log.user_id: user_id,
@ -540,6 +544,16 @@ class db_shortcuts:
Audit_Log.log_msg: audit_msg,
Audit_Log.source_ip: source_ip
}).execute()
@staticmethod
def add_to_audit_log_raw(user_name, user_id, server_id, log_msg, source_ip):
Audit_Log.insert({
Audit_Log.user_name: user_name,
Audit_Log.user_id: user_id,
Audit_Log.server_id: server_id,
Audit_Log.log_msg: log_msg,
Audit_Log.source_ip: source_ip
}).execute()

View File

@ -4,10 +4,12 @@ import json
import time
import logging
import threading
import asyncio
from app.classes.shared.helpers import helper
from app.classes.shared.console import console
from app.classes.web.tornado import webserver
from app.classes.web.websocket_helper import websocket_helper
from app.classes.minecraft.stats import stats
from app.classes.shared.controller import controller
@ -24,6 +26,7 @@ except ModuleNotFoundError as e:
console.critical("Import Error: Unable to load {} module".format(e, e.name))
sys.exit(1)
class TasksManager:
def __init__(self):
@ -41,6 +44,9 @@ class TasksManager:
self.command_thread = threading.Thread(target=self.command_watcher, daemon=True, name="command_watcher")
self.command_thread.start()
self.realtime_thread = threading.Thread(target=self.realtime, daemon=True, name="realtime")
self.realtime_thread.start()
def get_main_thread_run_status(self):
return self.main_thread_exiting
@ -75,10 +81,8 @@ class TasksManager:
db_helper.mark_command_complete(c.get('command_id', None))
time.sleep(1)
def _main_graceful_exit(self):
try:
os.remove(helper.session_file)
@ -136,10 +140,43 @@ class TasksManager:
logger.info("Scheduling Serverjars.com cache refresh service every 12 hours")
schedule.every(12).hours.do(server_jar_obj.refresh_cache)
@staticmethod
def realtime():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
host_stats = db_helper.get_latest_hosts_stats()
while True:
if host_stats.get('cpu_usage') != \
db_helper.get_latest_hosts_stats().get('cpu_usage') or \
host_stats.get('mem_percent') != \
db_helper.get_latest_hosts_stats().get('mem_percent'):
# Stats are different
host_stats = db_helper.get_latest_hosts_stats()
if len(websocket_helper.clients) > 0:
# There are clients
websocket_helper.broadcast('update_host_stats', {
'cpu_usage': host_stats.get('cpu_usage'),
'cpu_cores': host_stats.get('cpu_cores'),
'cpu_cur_freq': host_stats.get('cpu_cur_freq'),
'cpu_max_freq': host_stats.get('cpu_max_freq'),
'mem_percent': host_stats.get('mem_percent'),
'mem_usage': host_stats.get('mem_usage')
})
time.sleep(4)
else:
# Stats are same
time.sleep(8)
def log_watcher(self):
console.debug('in log_watcher')
helper.check_for_old_logs(db_helper)
schedule.every(6).hours.do(lambda: helper.check_for_old_logs(db_helper))
tasks_manager = TasksManager()

View File

@ -173,7 +173,7 @@ class ServerHandler(BaseHandler):
if new_server_id:
db_helper.add_to_audit_log(user_data['user_id'],
"Created server {} named {}".format(server, server_name),
"created a {} {} server named \"{}\"".format(server_parts[1], str(server_parts[0]).capitalize(), server_name), # Example: Admin created a 1.16.5 Bukkit server named "survival"
new_server_id,
self.get_remote_ip())
else:

View File

@ -24,6 +24,7 @@ try:
from app.classes.web.server_handler import ServerHandler
from app.classes.web.ajax_handler import AjaxHandler
from app.classes.web.api_handler import ServersStats, NodeStats
from app.classes.web.websocket_handler import SocketHandler
except ModuleNotFoundError as e:
logger.critical("Import Error: Unable to load {} module".format(e, e.name))
@ -125,6 +126,7 @@ class webserver:
(r'/ajax/(.*)', AjaxHandler),
(r'/api/stats/servers', ServersStats),
(r'/api/stats/node', NodeStats),
(r'/ws', SocketHandler),
]
app = tornado.web.Application(

View File

@ -0,0 +1,54 @@
import json
import tornado.websocket
from app.classes.shared.console import console
from app.classes.shared.models import Users, db_helper
from app.classes.web.websocket_helper import websocket_helper
class SocketHandler(tornado.websocket.WebSocketHandler):
def get_remote_ip(self):
remote_ip = self.request.headers.get("X-Real-IP") or \
self.request.headers.get("X-Forwarded-For") or \
self.request.remote_ip
return remote_ip
def check_auth(self):
user_data_cookie_raw = self.get_secure_cookie('user_data')
if user_data_cookie_raw and user_data_cookie_raw.decode('utf-8'):
user_data_cookie = user_data_cookie_raw.decode('utf-8')
user_id = json.loads(user_data_cookie)['user_id']
query = Users.select().where(Users.user_id == user_id)
if query.exists():
return True
return False
def open(self):
if self.check_auth():
self.handle()
else:
websocket_helper.send_message(self, 'notification', 'Not authenticated for WebSocket connection')
self.close()
db_helper.add_to_audit_log_raw('unknown', 0, 0, 'Someone tried to connect via WebSocket without proper authentication', self.get_remote_ip())
websocket_helper.broadcast('notification', 'Someone tried to connect via WebSocket without proper authentication')
def handle(self):
websocket_helper.addClient(self)
console.debug('Opened WebSocket connection')
# websocket_helper.broadcast('notification', 'New client connected')
def on_message(self, rawMessage):
console.debug('Got message from WebSocket connection {}'.format(rawMessage))
message = json.loads(rawMessage)
console.debug('Event Type: {}, Data: {}'.format(message['event'], message['data']))
def on_close(self):
websocket_helper.removeClient(self)
console.debug('Closed WebSocket connection')
# websocket_helper.broadcast('notification', 'Client disconnected')

View File

@ -0,0 +1,33 @@
import json
from app.classes.shared.console import console
class WebSocketHelper:
clients = set()
def addClient(self, client):
self.clients.add(client)
def removeClient(self, client):
self.clients.add(client)
def send_message(self, client, event_type, data):
if client.check_auth():
message = str(json.dumps({'event': event_type, 'data': data}))
client.write_message(message)
def broadcast(self, event_type, data):
console.debug('Sending: ' + str(json.dumps({'event': event_type, 'data': data})))
for client in self.clients:
try:
self.send_message(client, event_type, data)
except:
pass
def disconnect_all(self):
console.info('Disconnecting WebSocket clients')
for client in self.clients:
client.close()
console.info('Disconnected WebSocket clients')
websocket_helper = WebSocketHelper()

View File

@ -58,6 +58,12 @@
<div class="main-panel">
<div class="warnings">
<div class="noscript-warning" style="padding: 20px; background-color: rgb(247, 151, 15);">
<div><strong>Warning: </strong>Crafty doesn't work properly when JavaScript isn't enabled!</div>
</div>
</div>
{% block content %}
{% end %}
@ -71,6 +77,58 @@
</div>
<style>
.notifications {
position: fixed;
width: 200px;
top: 70px;
right: 0px;
}
.notification {
position: relative;
box-sizing: border-box;
padding: 0.5rem;
padding-left: 0.7rem;
width: 180px;
margin-left: 10px;
margin-right: 10px;
background: #282a40;
transition: right 0.75s, opacity 0.75s, top 0.75s;
right: -6rem;
opacity: 0.1;
margin-bottom: 1rem;
z-index: 999;
top: 0px;
}
.notification.active {
right: 0rem;
opacity: 1;
}
.notification.remove {
right: 0rem;
opacity: 0.1;
top: -2rem;
}
.notification p {
margin: 0px;
width: calc(160.8px - 16px);
z-index: inherit;
}
.notification span {
position: absolute;
right: 0.5rem;
top: 0.46rem;
cursor: pointer;
font-weight: bold;
line-height: 20px;
font-size: 22px;
user-select: none;
z-index: inherit;
}
</style>
<div class="notifications"></div>
<script src="/static/assets/vendors/js/vendor.bundle.base.js"></script>
<script src="/static/assets/js/shared/off-canvas.js"></script>
<script src="/static/assets/js/shared/hoverable-collapse.js"></script>
@ -79,6 +137,9 @@
<script src="//cdnjs.cloudflare.com/ajax/libs/bootbox.js/5.4.0/bootbox.min.js"></script>
<script>
$('.noscript-warning').toggle();
//used to get cookies from browser - this is part of tornados xsrf protection - it's for extra security
function getCookie(name) {
var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
@ -103,6 +164,132 @@
});
});
});
{% if request.protocol == 'https' %}
let usingWebSockets = true;
let listenEvents = [];
try {
var wsInternal = new WebSocket('wss://' + location.host + '/ws');
wsInternal.onopen = function() {
console.log('opened WebSocket connection:', wsInternal)
};
wsInternal.onmessage = function (rawMessage) {
var message = JSON.parse(rawMessage.data);
console.log('got message: ', message)
listenEvents
.filter(listenedEvent => listenedEvent.event == message.event)
.forEach(listenedEvent => listenedEvent.callback(message.data))
};
wsInternal.onerror = function (errorEvent) {
console.error('WebSocket Error', errorEvent);
};
wsInternal.onclose = function (closeEvent) {
console.log('Closed WebSocket', closeEvent);
};
webSocket = {
on: function (event, callback) {
console.log('registered ' + event + ' event');
listenEvents.push({ event: event, callback: callback })
},
emit: function (event, data) {
var message = {
event: event,
data: data
}
wsInternal.send(JSON.stringify(message));
}
}
} catch (error) {
console.error('Error while making websocket helpers', error);
usingWebSockets = false;
}
{% else %}
let usingWebSockets = false;
warn('WebSockets are not supported in Crafty if not using the https protocol')
var webSocket;
{% end%}
function warn(message) {
var closeEl = document.createElement('span');
var strongEL = document.createElement('strong');
var msgEl = document.createElement('div');
closeEl.innerHTML = '&times;';
strongEL.textContent = 'Warning: ';
msgEl.append(strongEL, message);
closeEl.style.marginLeft = '15px';
closeEl.style.fontWeight = 'bold';
closeEl.style.float = 'right';
closeEl.style.fontSize = '22px';
closeEl.style.lineHeight = '20px';
closeEl.style.cursor = 'pointer';
closeEl.addEventListener('click', function () {this.parentElement.style.display='none';});
var parentEl = document.createElement('div');
parentEl.style.padding = '20px';
parentEl.style.backgroundColor = '#f7970f';
parentEl.appendChild(closeEl);
parentEl.appendChild(msgEl);
document.querySelector('.warnings').appendChild(parentEl);
}
function closeNotification(element) {
element.parentElement.classList.add('remove');
setTimeout(function () {
element.parentElement.remove();
}, 500);
}
function notify(message) {
console.log(`notify(${message}})`);
var paragraphEl = document.createElement('p');
var closeEl = document.createElement('span');
paragraphEl.textContent = message;
closeEl.innerHTML = '&times;';
closeEl.addEventListener('click', function () {closeNotification(this)});
var parentEl = document.createElement('div');
parentEl.appendChild(paragraphEl);
parentEl.appendChild(closeEl);
parentEl.classList.add('notification');
document.querySelector('.notifications').appendChild(parentEl);
setTimeout(function () {
parentEl.classList.add('active');
}, 200);
setTimeout(function (element) {
closeNotification(element);
}, 7500, closeEl);
`
<div class="notification">
<p>Hello, World! This text should overflow</p>
<span>&times;</span>
</div>
`
}
webSocket.on('notification', notify);
</script>
{% block js %}

View File

@ -34,12 +34,11 @@
</div>
<div class="wrapper my-auto ml-auto ml-lg-4">
<p class="mb-0 text-success" data-toggle="tooltip" data-placement="top" data-html="true" title="CPU Cores: {{ data.get('hosts_data').get('cpu_cores') }} <br /> CPU Cur Freq: {{ data.get('hosts_data').get('cpu_cur_freq') }} <br /> CPU Max Freq: {{ data.get('hosts_data').get('cpu_max_freq') }}" >
{{ data.get('hosts_data').get('cpu_usage') }} {{ _('CPU Usage') }}
<p id="cpu_data" class="mb-0 text-success" data-toggle="tooltip" data-placement="top" data-html="true" title="CPU Cores: {{ data.get('hosts_data').get('cpu_cores') }} <br /> CPU Cur Freq: {{ data.get('hosts_data').get('cpu_cur_freq') }} <br /> CPU Max Freq: {{ data.get('hosts_data').get('cpu_max_freq') }}" >
<span id="cpu_usage">{{ data.get('hosts_data').get('cpu_usage') }}</span> {{ _('CPU Usage') }}
</p>
<p class="mb-0 text-danger" data-toggle="tooltip" data-placement="top" title="Memory Usage: {{ data.get('hosts_data').get('mem_usage') }}" >
{{ data.get('hosts_data').get('mem_percent') }}% {{ _('Memory Usage') }}
<p id="mem_usage" class="mb-0 text-danger" data-toggle="tooltip" data-placement="top" title="Memory Usage: {{ data.get('hosts_data').get('mem_usage') }}" >
<span id="mem_percent">{{ data.get('hosts_data').get('mem_percent') }}%</span> {{ _('Memory Usage') }}
</p>
</div>
</div>
@ -268,6 +267,20 @@ $( document ).ready(function() {
message: '<div align="center"><i class="fas fa-spin fa-spinner"></i> &nbsp; Please be patient while we restart the server<br /> This screen will refresh in a moment </div>'
});
});
if (webSocket) {
cpu_data = document.getElementById('cpu_data');
cpu_usage = document.getElementById('cpu_usage');
mem_usage = document.getElementById('mem_usage');
mem_percent = document.getElementById('mem_percent');
webSocket.on('update_host_stats', function (hostStats) {
var cpuDataTitle = `CPU Cores: ${hostStats.cpu_cores} <br /> CPU Cur Freq: ${hostStats.cpu_cur_freq} <br /> CPU Max Freq: ${hostStats.cpu_max_freq}`;
cpu_data.setAttribute('data-original-title', cpuDataTitle);
cpu_usage.textContent = hostStats.cpu_usage;
mem_usage.setAttribute('data-original-title', `Memory Usage: ${hostStats.mem_usage}`);
mem_percent.textContent = hostStats.mem_percent + '%';
});
}
$( ".clone_button" ).click(function() {
server_id = $(this).attr("data-id");