diff --git a/webv4/components/mqtt.xjs b/webv4/components/mqtt.xjs
index 723573919cfa0b6bcdb9fcc5a674e075549355eb..d552fc873918f79160b37c2e0d0e594b8b26828c 100644
--- a/webv4/components/mqtt.xjs
+++ b/webv4/components/mqtt.xjs
@@ -1,31 +1,36 @@
 <?
     // Ensure the user is a sysop, mqtt broker handles sensitive info that should not be available to non-sysop users
     // (Ideally the page loading this component will check for SysOp status, but it's included here to be super safe)
-    if (!user.is_sysop) {
-?>
-        <!-- TODOZ Translate -->
-        <div class="alert alert-danger"><h3>You must be a SysOp to use MQTT-based features.</h3></div>
-<?
-        exit();
-    }
+    load(xjs_compile(settings.web_components + 'sysop-required.xjs'));
 
     var request = require({}, settings.web_lib + 'request.js', 'request');
     
     // Check for form posting back mqtt ports
     var mqtt_post_error = '';
-    if (request.get_param('mqtt_ws_port') && request.get_param('mqtt_wss_port')) {
+    if (request.get_param('mqtt_hostname') && request.get_param('mqtt_ws_port') && request.get_param('mqtt_wss_port')) {
+        var new_hostname = request.get_param('mqtt_hostname');
         var new_ws_port = parseInt(request.get_param('mqtt_ws_port'), 10);
         var new_wss_port = parseInt(request.get_param('mqtt_wss_port'), 10);
 
         if ((new_ws_port >= 1) && (new_ws_port <= 65535) && (new_wss_port >= 1) && (new_wss_port <= 65535)) {
             var f = new File(system.ctrl_dir + 'modopts.ini');
-            f.open("r+");
-            f.iniSetValue('web', 'mqtt_ws_port', new_ws_port);
-            f.iniSetValue('web', 'mqtt_wss_port', new_wss_port);
-            f.close();
-
-            settings.mqtt_ws_port = new_ws_port;
-            settings.mqtt_wss_port = new_wss_port;
+            if (f.open("r+")) {
+                try {
+                    f.iniSetValue('web', 'mqtt_hostname', new_hostname);
+                    f.iniSetValue('web', 'mqtt_ws_port', new_ws_port);
+                    f.iniSetValue('web', 'mqtt_wss_port', new_wss_port);
+
+                    settings.mqtt_hostname = new_hostname;
+                    settings.mqtt_ws_port = new_ws_port;
+                    settings.mqtt_wss_port = new_wss_port;
+                } catch (error) {
+                    mqtt_post_error = 'ERROR: ' + error.message;
+                } finally {
+                    f.close();
+                }
+            } else {
+                mqtt_post_error = 'ERROR: Unable to open modopts.ini for writing';
+            }
         } else {
             mqtt_post_error = 'ERROR: Ports must be in the range of 1 to 65535';
         }
@@ -33,14 +38,19 @@
 
     // Read mqtt settings
     var f = new File(system.ctrl_dir + 'main.ini');
-    f.open("r");
-    var broker_addr = f.iniGetValue('MQTT', 'Broker_addr', 'localhost');
-    var broker_enabled = f.iniGetValue('MQTT', 'Enabled', false);
-    var broker_password = f.iniGetValue('MQTT', 'Password', '');
-    var broker_username = f.iniGetValue('MQTT', 'Username', '');
-    var broker_ws_port = settings.mqtt_ws_port;
-    var broker_wss_port = settings.mqtt_wss_port;
-    f.close();
+    if (f.open("r")) {
+        try {
+            var broker_enabled = f.iniGetValue('MQTT', 'Enabled', false);
+            var broker_password = f.iniGetValue('MQTT', 'Password', '');
+            var broker_username = f.iniGetValue('MQTT', 'Username', '');
+
+            var broker_addr = settings.mqtt_hostname;
+            var broker_ws_port = settings.mqtt_ws_port;
+            var broker_wss_port = settings.mqtt_wss_port;
+        } finally {
+            f.close();
+        }
+    }
     
     // Abort if the mqtt broker is not enabled
     if (!broker_enabled) {
@@ -51,24 +61,31 @@
         exit();
     }
     
-    // Display configuration form if mqtt ws/wss ports are not set yet
-    if (!broker_ws_port || !broker_wss_port) {
+    // Display configuration form if mqtt addr and ws/wss ports are not set yet
+    if (!broker_addr || !broker_ws_port || !broker_wss_port) {
 ?>
         <!-- TODOZ Translate -->
         <div class="container">
             <div class="col-lg-6 col-lg-offset-3 col-md-7 col-md-offset-3 col-sm-9 col-sm-offset-2">
                 <div class="panel panel-default">
                     <div class="panel-heading">
-                        <h3>Missing MQTT port settings</h3>
+                        <h3>Missing MQTT settings</h3>
                     </div>
                     <div class="panel-body">
-                        <p>Please provide the WebSocket ports your MQTT server is listening on:</p>
+                        <p>Please provide the hostname and WebSocket ports your MQTT server is listening on:</p>
 
                         <form method="POST" class="form-horizontal">
                             <? if (mqtt_post_error) { ?>
                                 <div class="alert alert-danger"><? write(mqtt_post_error); ?></div>
                             <? } ?>
 
+                            <div class="form-group">
+                                <label for="mqtt_hostname" class="col-sm-3 control-label">Hostname</label>
+                                <div class="col-sm-9">
+                                    <input type="text" id="mqtt_hostname" name="mqtt_hostname" class="form-control" value="<? write(broker_addr === undefined ? '' : broker_addr); ?>" />
+                                </div>
+                            </div>
+
                             <div class="form-group">
                                 <label for="mqtt_ws_port" class="col-sm-3 control-label">WS:// port</label>
                                 <div class="col-sm-9">
@@ -92,8 +109,8 @@
                     </div>
                     <div class="panel-footer">
                         <p>
-                            Alternatively, you can edit <strong><? write(system.ctrl_dir + 'modopts.ini'); ?></strong> and add <strong>mqtt_ws_port</strong> and 
-                            <strong>mqtt_wss_port</strong> keys to the <strong>[web]</strong> section.
+                            Alternatively, you can edit <strong><? write(system.ctrl_dir + 'modopts.ini'); ?></strong> and add <strong>mqtt_hostname</strong>, 
+                            <strong>mqtt_ws_port</strong> and <strong>mqtt_wss_port</strong> keys to the <strong>[web]</strong> section.
                         </p>
                     </div>
                 </div>
@@ -115,7 +132,7 @@
 
     <ul>
         <li>The MQTT broker is offline.</li>
-        <li>The wrong hostname was specified in SCFG -> Networks -> MQTT (currently=<? write(broker_addr); ?>).</li>
+        <li>The wrong hostname was specified for the mqtt_hostname setting in modopts.ini (currently=<? write(broker_addr); ?>).</li>
         <li>The wrong port was specified for the mqtt_ws_port setting in modopts.ini (currently=<? write(broker_ws_port); ?>).</li>
         <li>A firewall is blocking the connection from your current location.</li>
     </ul>
@@ -134,7 +151,7 @@
 
     <ul>
         <li>The MQTT broker is offline.</li>
-        <li>The wrong hostname was specified in SCFG -> Networks -> MQTT (currently=<? write(broker_addr); ?>).</li>
+        <li>The wrong hostname was specified for the mqtt_hostname setting in modopts.ini (currently=<? write(broker_addr); ?>).</li>
         <li>The wrong port was specified for the mqtt_wss_port setting in modopts.ini (currently=<? write(broker_wss_port); ?>).</li>
         <li>The SSL cert has expired.</li>
         <li>You're connecting to the broker via a hostname not found in the SSL cert.</li>
diff --git a/webv4/components/sysop-required.xjs b/webv4/components/sysop-required.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..5b955f947d48c2e2597b8101935a8c6ad778ad73
--- /dev/null
+++ b/webv4/components/sysop-required.xjs
@@ -0,0 +1,10 @@
+<?
+    // Ensure the user is a sysop to avoid normal users from accessing the page this component is embedded on
+    if (!user.is_sysop) {
+?>
+        <!-- TODOZ Translate -->
+        <div class="alert alert-danger"><h3>You must be a SysOp to view this page.</h3></div>
+<?    
+        exit();
+    }
+?>
diff --git a/webv4/components/webmonitor/clients-panel.xjs b/webv4/components/webmonitor/clients-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..836a99f7fa85c33cec9b1b20501e03e54c9124cd
--- /dev/null
+++ b/webv4/components/webmonitor/clients-panel.xjs
@@ -0,0 +1,19 @@
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="clientsTab">
+    <div class="webmonitor-scrolling-wrapper" id="clientsTableWrapper">    
+        <table class="table table-bordered table-condensed table-hover table-striped">
+            <thead>
+                <tr>
+                    <th>Socket</th>
+                    <th>Protocol</th>
+                    <th>User</th>
+                    <th>Address</th>
+                    <th>Host Name</th>
+                    <th>Port</th>
+                    <th>Time</th>
+                </tr>
+            </thead>
+            <tbody id="clientsTable">
+            </tbody>
+        </table>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/documentation-panel.xjs b/webv4/components/webmonitor/documentation-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..941e9ff414de0411c4cd8df3fbb654a48535c33f
--- /dev/null
+++ b/webv4/components/webmonitor/documentation-panel.xjs
@@ -0,0 +1,37 @@
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="documentationTab">
+    <div class="webmonitor-scrolling-wrapper"> 
+        <h3>Enabling the Configure buttons</h3>
+        <p>
+            By default, the configure buttons are not enabled.  This is because it's still an experimental feature and not recommended
+            for use yet.  But if you'd like to test it out, <strong>and have backed up your sbbs.ini first</strong>, you can enable
+            the configure buttons by adding this key/value to the [web] section of ctrl/modopts.ini:
+        </p>
+        <ul>
+            <li>webmonitor_configuration_enabled=true</li>
+        </ul>
+        <p class="small">NB: Only one tab of the Terminal Server Configuration is functional right now.</p>
+
+        <h3>Customizing the panel layout</h3>
+        <p>
+            By default, my preferred layout is used for the panels on the Web Monitor interface.  
+            If you'd like to restore the default SBBSCTRL layout of the panels, you can edit your ctrl/modopts.ini and add these keys/values to the [web] section:
+        </p>
+        <ul>
+            <li>webmonitor_quadrant_top_left=nodes,clients,statistics</li>
+            <li>webmonitor_quadrant_top_right=mail</li>
+            <li>webmonitor_quadrant_bottom_left=term,events</li>
+            <li>webmonitor_quadrant_bottom_right=srvc,ftp,web</li>
+        </ul>
+        <p>
+            Of course you can customize the layout by changing the order of the values, or even moving them to a different quadrant.  And if you want
+            to hide a panel, just don't include it as a value in any of the four keys.
+        </p>
+        <p>
+            You may want to also include "documentation" as a value at the end of one of the four keys, just in case this panel is updated with new
+            information in the future.
+        </p>
+        <p>
+            There's also a new "recent-visitors" panel that you should account for if you're going to customize the four keys.
+        </p>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/events-panel.xjs b/webv4/components/webmonitor/events-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..d16b78f808440b8fde7defad5ea8341f4ae19352
--- /dev/null
+++ b/webv4/components/webmonitor/events-panel.xjs
@@ -0,0 +1,15 @@
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="eventsTab">
+    <div class="webmonitor-scrolling-wrapper" id="eventsTableWrapper">    
+        <table class="table table-bordered table-condensed table-hover">
+            <thead>
+                <tr>
+                    <th>Date/Time</th>
+                    <th>Level</th>
+                    <th>Message</th>
+                </tr>
+            </thead>
+            <tbody id="eventsLog">
+            </tbody>
+        </table>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/nodes-panel.xjs b/webv4/components/webmonitor/nodes-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..d61b4890aab4e0f1ccf2cda2aecb7d5327906fcf
--- /dev/null
+++ b/webv4/components/webmonitor/nodes-panel.xjs
@@ -0,0 +1,24 @@
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="nodesTab">
+    <div class="webmonitor-scrolling-wrapper" id="nodesTableWrapper">    
+        <table class="table table-bordered table-condensed table-hover table-striped">
+            <thead>
+                <tr>
+                    <th>Node</th>
+                    <th>Status</th>
+                </tr>
+            </thead>
+            <tbody>
+                <? for (var node = 1; node <= system.nodes; node++) { ?>
+                    <tr>
+                        <td align="center" width="50"><? write(node); ?></td>
+                        <td>
+                            <span id="nodeStatusLabel<? write(node); ?>">Unknown Status</span>
+                            &nbsp; &nbsp;
+                            <a href="./?page=More/001-nodespy.xjs&node=<? write(node); ?>" target="_blank" class="btn btn-primary btn-xs visible-hover"><span class="glyphicon glyphicon-search"></span> Spy</a>
+                        </td>
+                    </tr>
+                <? } ?>
+            </tbody>
+        </table>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/quadrant.xjs b/webv4/components/webmonitor/quadrant.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..35d4c0911039a644d7b7db5c24c4e50f8948cd27
--- /dev/null
+++ b/webv4/components/webmonitor/quadrant.xjs
@@ -0,0 +1,68 @@
+<?
+    var server_panels = ['ftp', 'mail', 'srvc', 'term', 'web'];
+
+    // Read the modopts setting that controls what panels should appear in each quadrant
+    var panels = settings['webmonitor_quadrant_' + argv[0]];
+
+    // If there isn't a modopts setting for this quadrant, use the default
+    if (!panels) {
+        switch (argv[0]) {
+            case 'bottom_left':
+                panels = 'term,events';
+                break;
+            case 'bottom_right':
+                panels = 'srvc,web,ftp,documentation';
+                break;
+            case 'top_left':
+                panels = 'nodes,recent-visitors,statistics';
+                break;
+            case 'top_right':
+                panels = 'clients,mail';
+                break;
+        }
+    }
+
+    // Split the comma-separated string into an array we'll loop through later
+    panels = panels.split(',');
+
+    // Convert the panel name into a description (eg term into Terminal Server)
+    function get_panel_description(name) {
+        switch (name) {
+            case 'clients': return 'Clients';
+            case 'documentation': return 'Documentation';
+            case 'events': return 'Events';
+            case 'ftp': return 'FTP Server';
+            case 'mail': return 'Mail';
+            case 'nodes': return 'Nodes';
+            case 'recent-visitors': return 'Recent Visitors';
+            case 'srvc': return 'Services';
+            case 'statistics': return 'Statistics';
+            case 'term': return 'Terminal Server';
+            case 'web': return 'Web Server';
+            default: return name;
+        }
+    }
+?>
+
+<ul class="nav nav-tabs">
+    <?
+        // Loop through the panel list to build the list of tabs
+        for (var i = 0; i < panels.length; i++) {
+            var className = i == 0 ? 'active' : '';
+            var badge = panels[i] === 'clients' ? ' <span class="badge" id="' + panels[i] + 'CounterBadge">0</span>' : '';
+            write('<li class="' + className + '"><a href="#' + panels[i] + 'Tab" data-toggle="tab">' + get_panel_description(panels[i]) + badge + '</a></li>');
+        }
+    ?>
+</ul>
+<div class="tab-content">
+    <?
+        // Loop through the panel list to load the proper component panels
+        for (var i = 0; i < panels.length; i++) {
+            if (server_panels.includes(panels[i])) {
+                load(xjs_compile(settings.web_components + 'webmonitor/server-panel.xjs'), panels[i], get_panel_description(panels[i]), i == 0 ? 'active in' : '');
+            } else {
+                load(xjs_compile(settings.web_components + 'webmonitor/' + panels[i] + '-panel.xjs'), i == 0 ? 'active in' : '');
+            }
+        }
+    ?>
+</div>            
diff --git a/webv4/components/webmonitor/recent-visitors-panel.xjs b/webv4/components/webmonitor/recent-visitors-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..82cde0efeb734a6a8a7b6a8845a39cea5061b5b5
--- /dev/null
+++ b/webv4/components/webmonitor/recent-visitors-panel.xjs
@@ -0,0 +1,41 @@
+<?
+    var logonlist_lib = load({}, 'logonlist_lib.js');
+    var ll = logonlist_lib.get(-10);
+?>
+
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="recent-visitorsTab">
+    <div class="webmonitor-scrolling-wrapper" id="recent-visitorsTableWrapper">    
+        <table class="table table-bordered table-condensed table-hover table-striped">
+            <thead>
+                <tr>
+                    <th>Date/Time</th>
+                    <th>Alias</th>
+                    <th>Name</th>
+                    <th>Email</th>
+                    <th>Location</th>
+                    <th>Protocol</th>
+                </tr>
+            </thead>
+            <tbody id="recent-visitorsTable">
+<? 
+                if (Array.isArray(ll)) { 
+                    ll.forEach(function (e) { 
+?>
+                        <tr>
+                            <td><? write(new Date(e.time * 1000).toLocaleString()); ?></td>
+                            <td><? write(e.user.alias.replace(/</g, '&lt;')); ?></td>
+                            <td><? write(e.user.name.replace(/</g, '&lt;')); ?></td>
+                            <td><? write(e.user.netmail.replace(/</g, '&lt;')); ?></td>
+                            <td><? write(e.user.location.replace(/</g, '&lt;')); ?></td>
+                            <td><? write(e.user.connection); ?></td>
+                        </tr>
+<? 
+                    });
+                } else {
+                    write('<tr><td colspan="6" align="center">No recent visitors to display</td></tr>');
+                }
+?>
+            </tbody>
+        </table>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/server-panel.xjs b/webv4/components/webmonitor/server-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..89ec54bd8729eaaa076a8e1aae63628cbda9e956
--- /dev/null
+++ b/webv4/components/webmonitor/server-panel.xjs
@@ -0,0 +1,28 @@
+<?
+    var serverType = argv[0];
+    var serverDescription = argv[1];
+    var classNames = argv[2];
+?>
+
+<div class="tab-pane fade webmonitor-panel <? write(classNames); ?>" id="<? write(serverType); ?>Tab">
+    <div class="webmonitor-toolbar-buttons">
+        <button class="btn btn-default xbtn-sm" id="<? write(serverType); ?>StatusButton">Status: Unknown</button>
+        <button class="btn btn-default xbtn-sm webmonitor-recycle" data-server="<? write(serverType); ?>" data-toggle="tooltip" data-placement="bottom" title="Recycle <? write(serverDescription); ?>"><span class="glyphicon glyphicon-off"></span></button>
+        <button class="btn btn-default xbtn-sm" id="<? write(serverType); ?>ConfigureButton" data-toggle="tooltip" data-placement="bottom" title="Configure <? write(serverDescription); ?>"><span class="glyphicon glyphicon-wrench"></span></button>
+        <div class="checkbox pull-right"><label><input type="checkbox" id="<? write(serverType); ?>AutoScroll" checked="checked"> Autoscroll</label></div>
+    </div>
+    <div class="clearfix"></div>
+    <div class="webmonitor-scrolling-wrapper-with-buttons" id="<? write(serverType); ?>TableWrapper">
+        <table class="table table-bordered table-condensed table-hover">
+            <thead>
+                <tr>
+                    <th>Date/Time</th>
+                    <th>Level</th>
+                    <th>Message</th>
+                </tr>
+            </thead>
+            <tbody id="<? write(serverType); ?>Log">
+            </tbody>
+        </table>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/statistics-panel.xjs b/webv4/components/webmonitor/statistics-panel.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..b26007ba77e75913607358c62389d099d68addb4
--- /dev/null
+++ b/webv4/components/webmonitor/statistics-panel.xjs
@@ -0,0 +1,5 @@
+<div class="tab-pane fade webmonitor-panel <? write(argv[0]); ?>" id="statisticsTab">
+    <div class="webmonitor-scrolling-wrapper"> 
+        <h3>TODOX: Display statistics</h3>
+    </div>
+</div>
diff --git a/webv4/components/webmonitor/term-configuration-modal.xjs b/webv4/components/webmonitor/term-configuration-modal.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..bbc46d7a28f229541a851a1313bfd0a753bfdf76
--- /dev/null
+++ b/webv4/components/webmonitor/term-configuration-modal.xjs
@@ -0,0 +1,61 @@
+<div class="modal fade" id="termModal" tabindex="-1" role="dialog" aria-labelledby="termModalLabel">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+                <h4 class="modal-title" id="termModalLabel">Terminal Server Configuration</h4>
+            </div>
+            <div class="modal-body" id="termModalLoading">
+                <h1 align="center">Loading...</h1>
+                <!-- TODOZ Maybe a nice loading image? -->
+            </div>
+            <div class="modal-body" id="termModalBody" style="display: none;">
+                <ul class="nav nav-tabs">
+                    <li class="active"><a href="#termGeneralTab" data-toggle="tab">General</a></li>
+                    <li><a href="#termTelnetTab" data-toggle="tab">Telnet</a></li>
+                    <li><a href="#termRLoginTab" data-toggle="tab">RLogin</a></li>
+                    <li><a href="#termSSHTab" data-toggle="tab">SSH</a></li>
+                    <li><a href="#termSoundTab" data-toggle="tab">Sound</a></li>
+                </ul>
+                <div class="tab-content">
+                    <div class="tab-pane fade active in" id="termGeneralTab">
+                        <table class="table">
+                            <tr>
+                                <td width="50%">
+                                    <div>First Node<br /><input type="text" id="termFirstNode" class="form-control" value="1" style="width: 100px;" /></div>
+                                    <div>Last Node<br /><input type="text" id="termLastNode" class="form-control" value="4" style="width: 100px;" /></div>
+                                    <div>Max Concurrent Connections (0=no limit)<br /><input type="text" id="termMaxConcurrentConnections" class="form-control" value="N/A" style="width: 100px;" /></div>
+                                </td>
+                                <td width="50%">
+                                    <div class="checkbox"><label><input type="checkbox" id="termAutoStart"> Auto Startup</label></div>
+                                    <div class="checkbox"><label><input type="checkbox" id="termXTRN_MINIMIZED"> Minimize Externals</label></div>
+                                    <div class="checkbox"><label><input type="checkbox" id="termYES_EVENTS"> Events Enabled</label></div>
+                                    <div class="checkbox disabled"><label><input type="checkbox" id="termEventsLogFile" disabled="disabled"> Log Events to Disk</label></div>
+                                    <div class="checkbox"><label><input type="checkbox" id="termYES_QWK_EVENTS"> QWK Message Events</label></div>
+                                    <div class="checkbox"><label><input type="checkbox" id="termYES_DOS"> DOS Program Support</label></div>
+                                    <div class="checkbox"><label><input type="checkbox" id="termYES_HOST_LOOKUP"> Hostname Lookup</label></div>
+                                </td>
+                            </tr>
+                        </table>
+                    </div>
+                    <div class="tab-pane fade" id="termTelnetTab">
+                        <p>TODOX Telnet</p>
+                    </div>
+                    <div class="tab-pane fade" id="termRLoginTab">
+                        <p>TODOX RLogin</p>
+                    </div>
+                    <div class="tab-pane fade" id="termSSHTab">
+                        <p>TODOX SSH</p>
+                    </div>
+                    <div class="tab-pane fade" id="termSoundTab">
+                        <p>TODOX Sound</p>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+                <button type="button" class="btn btn-primary" id="termSaveChanges" style="display: none;">Save Changes</button>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/webv4/lib/auth.js b/webv4/lib/auth.js
index bf1583886373f60d40ea0efea8f23b93e9a82517..6dda22957564148556826e73a998fbcb5cb23a4c 100644
--- a/webv4/lib/auth.js
+++ b/webv4/lib/auth.js
@@ -1,4 +1,5 @@
 require('sbbsdefs.js', 'SYS_CLOSED');
+var request = require({}, settings.web_lib + 'request.js', 'request');
 
 function randomString(length) {
 	var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz'.split('');
@@ -11,6 +12,14 @@ function randomString(length) {
 	return str;
 }
 
+function getCsrfToken() {
+	if (is_user()) {
+		return getSessionValue(user.number, 'csrf_token');
+	} else {
+		return undefined;
+	}
+}
+
 function getSession(un) {
 	var fn = format('%suser/%04d.web', system.data_dir, un);
 	if (!file_exists(fn)) return false;
@@ -48,6 +57,31 @@ function setCookie(usr, sessionKey) {
 	}
 }
 
+function validateCsrfToken() {
+	try {
+		// Check for CSRF token (in header or query data)
+		var input_token = null;
+		if (http_request.header['x-csrf-token']) {
+			input_token = http_request.header['x-csrf-token'];
+		} else if (request.has_param('csrf_token')) {
+			input_token = request.get_param('csrf_token');
+		}
+
+		// If we didn't find an input token, then validation fails
+		if (!input_token) {
+			return false;
+		}
+
+		// If we did find an input token, confirm it matches the token stored in the user's session
+		return input_token === getCsrfToken();
+	} catch (error) {
+		// In case of error, return false to avoid allowing CSRF when one shouldn't be allowed
+		log(LOG_ERR, 'auth.js validateCsrfToken error: ' + error);
+		return false;
+	}
+}
+
+
 function validateSession(cookies) {
 
 	var usr = new User(0);
@@ -75,6 +109,12 @@ function validateSession(cookies) {
 		setSessionValue(usr.number, 'ip_address', client.ip_address);
 		if (session.session_start === undefined || time() - parseInt(session.session_start, 10) > settings.timeout) {
 			setSessionValue(usr.number, 'session_start', time());
+			
+			// Generate a csrf token.  Minimum recommended is 128 bits of entropy, and 43 characters of 0-9A-Za-z should equal 256 bits of entropy
+			// according to this formula: log2(62^43) -- https://www.wolframalpha.com/input?i=log2%2862%5E43%29
+			// (62 refers to the fact that there are 62 characters to choose from in the 0-9A-Za-z set)
+			setSessionValue(usr.number, 'csrf_token', randomString(43))
+			
 			if(!usr.is_sysop || (system.settings&SYS_SYSSTAT)) {
 				load({}, 'logonlist_lib.js').add({ node: 'Web' });
 			}
diff --git a/webv4/pages/More/001-nodespy.xjs b/webv4/pages/More/001-nodespy.xjs
index fd85ec66142abe7948bdb8d5d6dd1e1e50bd13b9..23a197b9264b04c7685336bea52f00a65da0a7f3 100644
--- a/webv4/pages/More/001-nodespy.xjs
+++ b/webv4/pages/More/001-nodespy.xjs
@@ -1,13 +1,7 @@
 <!--NO_SIDEBAR:Node Spy-->
 
 <?
-    // Ensure the user is a sysop to avoid normal users from spying
-    if (!user.is_sysop) {
-?>
-        <div class="alert alert-danger"><h3>You must be a SysOp to view the node spy</h3></div> <!-- TODOX Translate -->
-<?    
-        exit();
-    }
+    load(xjs_compile(settings.web_components + 'sysop-required.xjs'));
 
     // Load fTelnet-related files
     load(settings.web_lib + 'ftelnet.js');
@@ -36,7 +30,7 @@
 
 <h1 align="center">Node Spy</h1>
 
-<? loadComponent('mqtt.xjs'); ?>
+<? load(xjs_compile(settings.web_components + 'mqtt.xjs')); ?>
 
 <!-- Multiple fTelnet instances (one per node) will be added to this wrapper div -->
 <div id="ClientsWrapper"></div>
diff --git a/webv4/pages/More/001-webmonitor.xjs b/webv4/pages/More/001-webmonitor.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..f1912d646af40b7bb3da9ea0b63a1f5cd05934fd
--- /dev/null
+++ b/webv4/pages/More/001-webmonitor.xjs
@@ -0,0 +1,62 @@
+<!--NO_SIDEBAR:Web Monitor-->
+
+<? 
+    load(xjs_compile(settings.web_components + 'sysop-required.xjs')); 
+    load(xjs_compile(settings.web_components + 'mqtt.xjs'));
+?>
+
+<style>
+    div.webmonitor-panel {
+        height: 400px;
+    }
+    div.webmonitor-scrolling-wrapper {
+        height: 400px;
+        overflow-y: scroll;
+    }
+    div.webmonitor-scrolling-wrapper-with-buttons {
+        height: 356px;
+        overflow-y: scroll;
+    }
+    div.webmonitor-toolbar-buttons {
+        padding: 5px 0;
+    }
+    div.modal-body input[type=text] {
+        margin-bottom: 10px;
+    }
+    tr .visible-hover {
+        display: none;
+    }
+    tr:hover .visible-hover {
+        display: inline-block;
+    }
+</style>
+
+<div id="webmonitor">
+    <div class="row">
+        <div class="col-md-6">
+            <? load(xjs_compile(settings.web_components + 'webmonitor/quadrant.xjs'), 'top_left'); ?>
+        </div>
+        <div class="col-md-6">
+            <? load(xjs_compile(settings.web_components + 'webmonitor/quadrant.xjs'), 'top_right'); ?>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-6">
+            <? load(xjs_compile(settings.web_components + 'webmonitor/quadrant.xjs'), 'bottom_left'); ?>
+        </div>
+        <div class="col-md-6">
+            <? load(xjs_compile(settings.web_components + 'webmonitor/quadrant.xjs'), 'bottom_right'); ?>
+        </div>
+    </div>
+</div>
+
+<? load(xjs_compile(settings.web_components + 'webmonitor/term-configuration-modal.xjs')); ?>
+
+<script>
+    // Set javascript variables for system variables referenced by webmonitor.js
+    var csrf_token = '<? write(getCsrfToken()); ?>';
+    var webmonitor_configuration_enabled = <? write(settings.webmonitor_configuration_enabled); ?>;
+    var system_local_host_name = '<? write(system.local_host_name); ?>';
+    var system_qwk_id = '<? write(system.qwk_id); ?>';
+</script>
+<script src="./js/webmonitor.js"></script>
\ No newline at end of file
diff --git a/webv4/pages/More/webctrl.ini b/webv4/pages/More/webctrl.ini
index 1db5cd48ef4d1bb234f879901dadf4da26d7388c..b558862628f2f5c6ab9ff1faeffe95ed8f36b6e7 100644
--- a/webv4/pages/More/webctrl.ini
+++ b/webv4/pages/More/webctrl.ini
@@ -7,3 +7,5 @@ AccessRequirements = level 90
 [*userlist.xjs]
 AccessRequirements = LEVEL 50 AND REST NOT G
 
+[*webmonitor.xjs]
+AccessRequirements = level 90
diff --git a/webv4/root/api/webmonitor/get-term.ssjs b/webv4/root/api/webmonitor/get-term.ssjs
new file mode 100644
index 0000000000000000000000000000000000000000..65206732b88447efba1ff745997427f953afc603
--- /dev/null
+++ b/webv4/root/api/webmonitor/get-term.ssjs
@@ -0,0 +1,39 @@
+require('sbbsdefs.js', 'SYS_CLOSED');
+var settings = load('modopts.js', 'web') || { web_directory: '../webv4' };
+load(settings.web_directory + '/lib/init.js');
+load(settings.web_lib + 'auth.js');
+
+var reply = {};
+
+function validateRequest() {
+    if (!user.is_sysop) {
+        reply = { error: 'You must be a SysOp to use this API' }
+        return false;
+    } else if (!validateCsrfToken()) {
+        reply = { error: 'Invalid or missing CSRF token' }
+        return false;
+    }
+
+    return true;
+}
+
+if (validateRequest()) {
+    // Read terminal server settings
+    var f = new File(system.ctrl_dir + 'sbbs.ini');
+    if (f.open("r")) {
+        try {
+            reply = f.iniGetObject('BBS');
+        } catch (error) {
+            reply = { error: error.message };
+        } finally {
+            f.close();
+        }
+    } else {
+        reply = { error: 'Unable to open sbbs.ini for reading' }
+    }
+}
+
+reply = JSON.stringify(reply);
+http_reply.header['Content-Type'] = 'application/json';
+http_reply.header['Content-Length'] = reply.length;
+write(reply);
diff --git a/webv4/root/api/webmonitor/update-term.ssjs b/webv4/root/api/webmonitor/update-term.ssjs
new file mode 100644
index 0000000000000000000000000000000000000000..1bafd217867179cc4d41f94020a3dd005b7b6bfd
--- /dev/null
+++ b/webv4/root/api/webmonitor/update-term.ssjs
@@ -0,0 +1,156 @@
+require('sbbsdefs.js', 'SYS_CLOSED');
+var settings = load('modopts.js', 'web') || { web_directory: '../webv4' };
+load(settings.web_directory + '/lib/init.js');
+load(settings.web_lib + 'auth.js');
+var request = require({}, settings.web_lib + 'request.js', 'request');
+
+var reply = { errors: [], skipped: [], updated: [] };
+
+function validateRequest() {
+    if (!user.is_sysop) {
+        reply.errors.push({ key: '', message: 'You must be a SysOp to use this API' });
+        return false;
+    } else if (!validateCsrfToken()) {
+        reply.errors.push({ key: '', message: 'Invalid or missing CSRF token' });
+        return false;
+    }
+
+    return true;
+}
+
+if (validateRequest()) {
+    // Update terminal server settings
+    var f = new File(system.ctrl_dir + 'sbbs.ini');
+    if (f.open("r+")) {
+        try {
+            updateIntValue(f, 'FirstNode', 1, 255);
+            updateIntValue(f, 'LastNode', 1, 255);
+            updateIntValue(f, 'MaxConcurrentConnections', 0, 255);
+            updateBoolValue(f, 'AutoStart');
+            updateOptions(f, 'XTRN_MINIMIZED');
+            updateOptions(f, 'NO_EVENTS');
+            updateOptions(f, 'NO_QWK_EVENTS');
+            updateOptions(f, 'NO_DOS');
+            updateOptions(f, 'NO_HOST_LOOKUP');
+        } catch (error) {
+            reply.errors.push({ key: '', message: error.message });
+        } finally {
+            f.close();
+        }
+    } else {
+        reply.errors.push({ key: '', message: 'Unable to open sbbs.ini for writing' });
+    }
+}
+
+reply = JSON.stringify(reply);
+http_reply.header['Content-Type'] = 'application/json';
+http_reply.header['Content-Length'] = reply.length;
+write(reply);
+
+
+
+function updateBoolValue(f, key) {
+    // Check if parameter was sent in request
+    if (!request.has_param(key)) {
+        reply.errors.push({ key: key, message: 'Missing parameter' });
+        return;
+    }
+
+    // Check if parameter has a valid value
+    var re = /^(true|false)$/i;
+    if (!re.test(request.get_param(key))) {
+        reply.errors.push({ key: key, value: request.get_param(key), message: 'Value is not boolean' });
+        return;
+    }
+
+    // Skip if new value matches existing value
+    var newValue = JSON.parse(request.get_param(key).toLowerCase());
+    if (newValue == f.iniGetValue('BBS', key, false)) {
+        reply.skipped.push({ key: key, value: request.get_param(key) });
+        return;
+    }
+
+    // Update the ini
+    f.iniSetValue('BBS', key, newValue);
+    reply.updated.push({ key: key, value: newValue });
+}
+
+function updateIntValue(f, key, min, max) {
+    // Check if parameter was sent in request
+    if (!request.has_param(key)) {
+        reply.errors.push({ key: key, message: 'Missing parameter' });
+        return;
+    }
+
+    // Check if parameter has a valid value
+    var re = /^\d+$/;
+    if (!re.test(request.get_param(key))) {
+        reply.errors.push({ key: key, value: request.get_param(key), message: 'Value is not numeric' });
+        return;
+    }
+
+    // Check if value is in allowed range
+    var newValue = parseInt(request.get_param(key), 10);
+    if ((newValue < min) || (newValue > max)) {
+        reply.errors.push({ key: key, value: request.get_param(key), message: 'Value is not in range (' + min + '-' + max + ')' });
+        return;
+    }
+     
+    // Skip if new value matches existing value
+    if (newValue == f.iniGetValue('BBS', key, 0)) {
+        reply.skipped.push({ key: key, value: request.get_param(key) });
+        return;
+    }
+
+    // Update the ini
+    f.iniSetValue('BBS', key, newValue);
+    reply.updated.push({ key: key, value: newValue });
+}
+
+function updateOptions(f, option) {
+    // Check if parameter was sent in request
+    if (!request.has_param(option)) {
+        reply.errors.push({ key: option, message: 'Missing parameter' });
+        return;
+    }
+
+    // Check if option has a valid name
+    var re = /^[A-Z0-9_]+$/;
+    if (!re.test(option)) {
+        reply.errors.push({ key: option, value: request.get_param(option), message: 'Option is not valid' });
+        return;
+    }
+
+    // Check if parameter has a valid value
+    var re = /^(true|false)$/i;
+    if (!re.test(request.get_param(option))) {
+        reply.errors.push({ key: option, value: request.get_param(option), message: 'Value is not boolean' });
+        return;
+    }
+
+    // Read the existing options
+    var options = f.iniGetValue('BBS', 'Options', '');
+    if (options) {
+        options = options.split(/\s*[|]\s*/);
+    } else {
+        options = [];
+    }
+
+    // Skip if new value matches existing value
+    var enableOption = JSON.parse(request.get_param(option).toLowerCase());
+    if (enableOption == options.includes(option)) {
+        reply.skipped.push({ key: option, value: request.get_param(option) });
+        return;
+    }
+
+    // Add or remove option based on value
+    if (enableOption) {
+        options.push(option);
+    } else {
+        options.splice(options.indexOf(option), 1);
+    }
+
+    // Update the ini
+    f.iniSetValue('BBS', 'Options', options.join(' | '));
+    reply.updated.push({ key: option, value: enableOption });
+}
\ No newline at end of file
diff --git a/webv4/root/js/mqtt.js b/webv4/root/js/mqtt.js
index 51d77b3ffd866760a05da60f274482807dbe89a4..4d813eba7737f5caba890f8a70c32710745fb565 100644
--- a/webv4/root/js/mqtt.js
+++ b/webv4/root/js/mqtt.js
@@ -8,7 +8,7 @@ function mqtt_connect(topics, message_callback, log_callback) {
         username: broker_username,
         password: broker_password,
         protocolId: 'MQTT',
-        protocolVersion: 4,
+        protocolVersion: 5,
         clean: true,
         reconnectPeriod: 5000,
         connectTimeout: 30 * 1000,
diff --git a/webv4/root/js/webmonitor.js b/webv4/root/js/webmonitor.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc36f366ef13592485d7d47a49999024a0b9e3a3
--- /dev/null
+++ b/webv4/root/js/webmonitor.js
@@ -0,0 +1,393 @@
+var client_lists = [];
+    
+function handle_node_message(node, messageType, message) {
+    switch (messageType) {
+        case undefined:
+            // In “High” Publish Verbosity mode, human-readable node status messages are published directly to node/+ topics
+            // Example: sbbs/MYBBS/node/1 = Bubbaboy at external program menu via telnet
+            $('#nodeStatusLabel' + node).html(message);
+            break;
+        
+        case 'output':
+            // sbbs/+/node/+/output - live output to connected-terminal (for spying)
+            // Ignore, we have a separate page for node spy
+            break;
+
+        case 'status':
+            // sbbs/+/node/+/status - tab-delimited node status values (see load/nodedefs.js for details)
+            // Example: sbbs/VERT/node/1/status = 0       0       1       65535   0       0       0       7
+            // Ignore
+            break;
+        
+        case 'terminal':
+            // sbbs/+/node/+/terminal - tab-delimited current (or last) connected-terminal definition
+            // Example: sbbs/VERT/node/1/terminal = 80    24      syncterm        ANSI    CP437   6       0       2005
+            // Ignore
+            break;
+
+        default:
+            console.log('Unhandled node message: ' + JSON.stringify({node, messageType, message}));
+            break;
+    }        
+}
+
+function handle_resize() {
+    // Resize tabs to fit height of window
+    // 51 = navbar height, 20 = navbar margin bottom, 42 = top tab height, 42 = bottom tab height
+    var tabHeight = parseInt((window.innerHeight - 51 - 20 - 42 - 42) / 2, 10);
+    if (tabHeight < 200) {
+        tabHeight = 200;
+    }
+    $('div.webmonitor-panel').css('height', tabHeight + 'px');
+    $('div.webmonitor-scrolling-wrapper').css('height', tabHeight + 'px');
+    // 44 = height of buttons div
+    $('div.webmonitor-scrolling-wrapper-with-buttons').css('height', (tabHeight - 44) + 'px');
+}
+
+function handle_server_message(server, subtopic, subtopic2, message, packet) {
+    var ignoredSubtopics = ['error_count', 'served', 'state', 'version'];
+    var ignoredClientSubtopics = [undefined, 'action'];
+
+    if (ignoredSubtopics.includes(subtopic)) {
+        // Ignore, not handling these subtopics at this time
+        return;
+    } else if ((subtopic === 'client') && ignoredClientSubtopics.includes(subtopic2)) {
+        // Ignore, not handling these client subtopics at this time
+        return;
+    } else if (subtopic === undefined) {
+        // sbbs/+/host/+/server/+ - tab-delimited status of each server is published to its server topic
+        // Examples:
+        //      ready		8 served
+        //      ready	32/150 clients	28 served	
+        //      DISCONNECTED
+        // Easier to handle here for all servers, instead of individually in the switch statement below
+        var status = message.split('\t')[0];
+        switch (status.toLowerCase()) {
+            case 'disconnected': status = 'Stopped'; break;
+            case 'initializing': status = 'Starting'; break;
+            case 'ready': status = 'Running'; break;
+            case 'reloading': status = 'Restarting'; break;
+            case 'stopped': status = 'Stopped'; break;
+            case 'stopping': status = 'Stopping'; break;
+            default: console.log('Unknown Server Status: ' + status); break;
+        }
+        $('#' + server + 'StatusButton').text('Status: ' + status);
+        return;
+    } else if ((subtopic === 'client') && (subtopic2 === 'list')) {
+        // sbbs/+/host/+/server/+/client_list - tab-delimited details of all connected clients, one client per line
+        // Examples: 
+        //      20230813T134846-240	Telnet	1	Ree	::1	localhost	54400	13184
+        //      20230813T151452-240	Telnet	0	<unknown user>	::1	localhost	59258	18256
+        // There is a timer set to fire every second, which will read client_lists[] and update the display, hence why
+        // nothing much is happening here
+        client_lists[server] = message;
+        return;
+    } else if (subtopic === 'log') {
+        // sbbs/+/host/+/server/+/log
+        // Each server/+ and event sub-topics has a log child topic where all messages of all log levels (severity) will be published as well as a grandchild topic for each log level (0-7, decreasing in severity) of logged messages.
+        // Easier to handle here for all servers, instead of individually in the switch statement below
+        if (subtopic2 !== undefined) {
+            var level = subtopic2;
+            try {
+                var dateTime = timestamp_to_date(packet.properties.userProperties.time).toLocaleString();
+            } catch {
+                var dateTime = new Date().toLocaleString();
+            }
+            $('#' + server + 'Log').append(`<tr class="${level_to_classname(level)}"><td style="white-space: nowrap;">${dateTime}</td><td align="center">${level_to_description(level)}</td><td>${message}</td></tr>`);
+            
+            if ($('#' + server + 'AutoScroll').is(':checked')) {
+                var tableWrapper = document.getElementById(server + 'TableWrapper');
+                tableWrapper.scrollTop = tableWrapper.scrollHeight;
+            }
+        }
+        return;
+    }
+
+    // If we get here, the message wasn't expected
+    console.log('Unhandled server->' + server + ' message: ' + JSON.stringify({server, subtopic, subtopic2, message}));
+}
+
+function level_to_classname(level) {
+    switch (parseInt(level, 10)) {
+        case 0: return 'danger';
+        case 1: return 'danger';
+        case 2: return 'danger';
+        case 3: return 'danger';
+        case 4: return 'warning';
+        case 5: return 'info';
+        case 6: return '';
+        case 7: return 'success';
+        default: return 'active';
+    }
+}
+
+function level_to_description(level) {
+    switch (parseInt(level, 10)) {
+        case 0: return 'Emergency';
+        case 1: return 'Alert';
+        case 2: return 'Critical';
+        case 3: return 'Error';
+        case 4: return 'Warning';
+        case 5: return 'Notice';
+        case 6: return 'Normal';
+        case 7: return 'Debug';
+        default: return 'Unknown';
+    }
+}
+
+function mqtt_message(topic, message, packet) {
+    // Convert the message to a string and make it safe to display by replacing < with &lt;
+    message = message.toString().replace(/</g, '&lt;');
+
+    var arr = topic.split('/');
+    if (arr[0] !== 'sbbs') {
+        console.log('ERROR: topic does not start with "sbbs": ' + JSON.stringify({topic, message}));
+        return;
+    } else if (arr[1] !== system_qwk_id) {
+        console.log('ERROR: topic does not start with BBSID (' + system_qwk_id + '): ' + JSON.stringify({topic, message}));
+        return;
+    }
+
+    switch (arr[2]) {
+        case undefined:
+            // Ignore, message contains BBS Name
+            break;
+
+        case 'action':
+            // Ignore, not handling anything under action at this time
+            break;
+
+        case 'host':
+            // NB: arr[3] will be the host name
+            switch (arr[4]) {
+                case 'event':
+                    switch (arr[5]) {
+                        case undefined:
+                            // Ignore, just says "thread started"
+                            break;
+
+                        case 'log':
+                            var level = arr[6];
+                            try {
+                                var dateTime = timestamp_to_date(packet.properties.userProperties.time).toLocaleString();
+                            } catch {
+                                var dateTime = new Date().toLocaleString();
+                            }
+                            $('#eventsLog').append(`<tr class="${level_to_classname(level)}"><td style="white-space: nowrap;">${dateTime}</td><td align="center">${level_to_description(level)}</td><td>${message}</td></tr>`);
+                            
+                            var tab = document.getElementById('eventsTab');
+                            tab.scrollTop = tab.scrollHeight;
+                            break;
+
+                        default:
+                            console.log('ERROR: Unexpected topic element (5=' + arr[5] + '): ' + JSON.stringify({topic, message}));
+                            break;
+                    }
+                    break;
+
+                case 'server':
+                    var server = arr[5];
+                    var subtopic = arr[6];
+                    var subtopic2 = arr[7];
+                    handle_server_message(server, subtopic, subtopic2, message, packet);
+                    break;
+
+                default:
+                    console.log('ERROR: Unexpected topic element (4=' + arr[4] + '): ' + JSON.stringify({topic, message}));
+                    break;
+            }
+            break;
+
+        case 'node':
+            var node = arr[3];
+            var messageType = arr[4];
+            handle_node_message(node, messageType, message);
+            break;
+
+        default:
+            console.log('ERROR: Unexpected topic element (2=' + arr[2] + '): ' + JSON.stringify({topic, message}));
+            break;
+    }
+}
+
+function timestamp_to_date(timestamp) {
+    return new Date(timestamp.slice(0, 4), timestamp.slice(4, 6) - 1, timestamp.slice(6, 8), timestamp.slice(9, 11), timestamp.slice(11, 13), timestamp.slice(13, 15))
+}
+
+function update_clients() {
+    $('#clientsTable').empty();
+    
+    // TODOY This displays clients in server order, might be nice to parse all the lists and sort by logonTime to show the longest-lived connections at the top
+    var totalClients = 0;
+    var servers = ['term', 'srvc', 'mail', 'ftp', 'web'];
+    for (var i = 0; i < servers.length; i++) {
+        if (client_lists[servers[i]]) {
+            var lines = client_lists[servers[i]].split('\n');
+            for (var j = 0; j < lines.length; j++) {
+                // Examples: 
+                //      20230813T134846-240	Telnet	1	Ree	::1	localhost	54400	13184
+                //      20230813T151452-240	Telnet	0	<unknown user>	::1	localhost	59258	18256
+                var arr = lines[j].split('\t');
+
+                var logonTime = timestamp_to_date(arr[0]);
+                var secondsElapsed = parseInt(((new Date()).getTime() - logonTime.getTime()) / 1000, 10);
+                var hoursElapsed = parseInt(Math.floor(secondsElapsed / 3600), 10);
+                secondsElapsed -= (hoursElapsed * 3600);
+                var minutesElapsed = parseInt(Math.floor(secondsElapsed / 60), 10);
+                secondsElapsed -= (minutesElapsed * 60);
+
+                var protocol = arr[1];
+                var username = arr[3];
+                var address = arr[4];
+                var hostname = arr[5];
+                var port = arr[6];
+                var socket = arr[7];
+
+                $('#clientsTable').append(`<tr><td>${socket}</td><td>${protocol}</td><td>${username}</td><td>${address}</td><td>${hostname}</td><td>${port}</td><td>${hoursElapsed}h ${minutesElapsed}m ${secondsElapsed}s</td></tr>`);
+                totalClients += 1;
+            }
+        }
+    }
+
+    $('#clientsCounterBadge').html(totalClients.toString());
+}
+
+// Add an onload handler to setup the node spy
+window.addEventListener('load', (event) => {
+    // Resize the panels to fit the window
+    handle_resize();
+
+    // Connect to mqtt broker and subscribe to a wildcard topic for this system
+    var topics = ['sbbs/' + system_qwk_id + '/#'];
+    mqtt_connect(topics, mqtt_message, console.log);
+
+    // Set a 1 second timer to update the time display on the clients list (maybe for other purposes later too)
+    setInterval(function() {
+        update_clients();
+    }, 1000);
+    
+    // Enable tooltips
+    $('[data-toggle="tooltip"]').tooltip()
+});
+
+window.addEventListener('resize', (event) => {
+    handle_resize();
+});
+
+$('.webmonitor-recycle').click(function() {
+    var server = $(this).data('server');
+    if (server) {
+        mqtt_publish('sbbs/' + system_qwk_id + '/host/' + system_local_host_name + '/server/' + server + '/recycle', 'recycle');
+    } else {
+        mqtt_publish('sbbs/' + system_qwk_id + '/host/' + system_local_host_name + '/recycle', 'recycle');
+    }
+});
+
+$('#ftpConfigureButton').click(function() {
+    if (!webmonitor_configuration_enabled) {
+        alert('Web Monitor Configuration is not enabled.\n\nSee the Documentation tab for more information.');
+        return;
+    }
+
+    alert('FTP Server Configuration is not implemented yet');
+});
+
+$('#mailConfigureButton').click(function() {
+    if (!webmonitor_configuration_enabled) {
+        alert('Web Monitor Configuration is not enabled.\n\nSee the Documentation tab for more information.');
+        return;
+    }
+
+    alert('Mail Server Configuration is not implemented yet');
+});
+
+$('#srvcConfigureButton').click(function() {
+    if (!webmonitor_configuration_enabled) {
+        alert('Web Monitor Configuration is not enabled.\n\nSee the Documentation tab for more information.');
+        return;
+    }
+
+    alert('Services Configuration is not implemented yet');
+});
+
+$('#termConfigureButton').click(function() {
+    if (!webmonitor_configuration_enabled) {
+        alert('Web Monitor Configuration is not enabled.\n\nSee the Documentation tab for more information.');
+        return;
+    }
+
+    $('#termModal').modal('show');
+
+    $.ajax({
+        url: './api/webmonitor/get-term.ssjs',
+        type: 'GET',
+        headers: { 'x-csrf-token': csrf_token }
+    }).done(function (data) {
+        if (data.error) {
+            alert(data.error); // TODOX Display on form instead of alert
+            $('#termModal').modal('hide');
+        } else {
+            $('#termModalLoading').hide();
+            $('#termModalBody').show();
+            $('#termSaveChanges').show();
+
+            $('#termFirstNode').val(data.FirstNode);
+            $('#termLastNode').val(data.LastNode);
+            $('#termMaxConcurrentConnections').val(data.MaxConcurrentConnections);
+            $('#termAutoStart').prop('checked', data.AutoStart);
+
+            var options = data.Options;
+            if (options) {
+                options = options.split(/\s*[|]\s*/)
+            } else {
+                options = [];
+            }
+
+            $('#termXTRN_MINIMIZED').prop('checked', options.includes('XTRN_MINIMIZED'));
+            $('#termYES_EVENTS').prop('checked', !options.includes('NO_EVENTS'));
+            $('#termYES_QWK_EVENTS').prop('checked', !options.includes('NO_QWK_EVENTS'));
+            $('#termYES_DOS').prop('checked', !options.includes('NO_DOS'));
+            $('#termYES_HOST_LOOKUP').prop('checked', !options.includes('NO_HOST_LOOKUP'));
+        }
+    });
+});
+
+$('#termSaveChanges').click(function() {
+    var requestModel = {
+        FirstNode: $('#termFirstNode').val(),
+        LastNode: $('#termLastNode').val(),
+        MaxConcurrentConnections: $('#termMaxConcurrentConnections').val(),
+        AutoStart: $('#termAutoStart').is(':checked'),
+        XTRN_MINIMIZED: $('#termXTRN_MINIMIZED').is(':checked'),
+        NO_EVENTS: !$('#termYES_EVENTS').is(':checked'),
+        NO_QWK_EVENTS: !$('#termYES_QWK_EVENTS').is(':checked'),
+        NO_DOS: !$('#termYES_DOS').is(':checked'),
+        NO_HOST_LOOKUP: !$('#termYES_HOST_LOOKUP').is(':checked'),
+    };
+
+    $.ajax({
+        url: './api/webmonitor/update-term.ssjs',
+        type: 'POST',
+        data: requestModel,
+        headers: { 'x-csrf-token': csrf_token }
+    }).done(function (data) {
+        if (data.errors.length > 0) {
+            var message = '';
+            for (var i = 0; i < data.errors.length; i++) {
+                message += data.errors[i].key + ' error: ' + data.errors[i].message + '\r\n';
+            }
+            alert(message); // TODOX Display on form instead of alert
+        } else {
+            $('#termModal').modal('hide');
+        }
+    });
+});
+
+$('#webConfigureButton').click(function() {
+    if (!webmonitor_configuration_enabled) {
+        alert('Web Monitor Configuration is not enabled.\n\nSee the Documentation tab for more information.');
+        return;
+    }
+
+    alert('Web Server Configuration is not implemented yet');
+});
diff --git a/webv4/sidebar/002-recent-visitors.xjs b/webv4/sidebar/002-recent-visitors.xjs
index 9c84161f7f0f2a38f276683c532c6d0530994cc7..be1869d34a2bcaa97203a81dea41d3ae3f763f54 100644
--- a/webv4/sidebar/002-recent-visitors.xjs
+++ b/webv4/sidebar/002-recent-visitors.xjs
@@ -13,7 +13,7 @@
     <? ll.reverse().forEach(function (e) { ?>
         <li class="list-group-item striped">
             <strong>
-                <? write(e.user.alias); ?>
+                <? write(e.user.alias.replace(/</g, '&lt;')); ?>
             </strong>
             <br>
             <em>
@@ -23,7 +23,7 @@
             <? if (e.user.location != '') { ?>
                 <? locale.write('label_location', 'sidebar_recent_visitors'); ?>
                 <strong>
-                    <? write(e.user.location); ?>
+                    <? write(e.user.location.replace(/</g, '&lt;')); ?>
                 </strong>
             <? } ?>
             <? locale.write('label_connection', 'sidebar_recent_visitors'); ?>