diff --git a/webv4/components/mqtt.xjs b/webv4/components/mqtt.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..723573919cfa0b6bcdb9fcc5a674e075549355eb
--- /dev/null
+++ b/webv4/components/mqtt.xjs
@@ -0,0 +1,165 @@
+<?
+    // 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();
+    }
+
+    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')) {
+        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;
+        } else {
+            mqtt_post_error = 'ERROR: Ports must be in the range of 1 to 65535';
+        }
+    }
+
+    // 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();
+    
+    // Abort if the mqtt broker is not enabled
+    if (!broker_enabled) {
+?>
+        <!-- TODOZ Translate -->
+        <div class="alert alert-danger"><h3>You must enable the MQTT broker in SCFG -> Networks -> MQTT before you can use the node spy</h3></div>
+<?
+        exit();
+    }
+    
+    // Display configuration form if mqtt ws/wss ports are not set yet
+    if (!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>
+                    </div>
+                    <div class="panel-body">
+                        <p>Please provide the 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_ws_port" class="col-sm-3 control-label">WS:// port</label>
+                                <div class="col-sm-9">
+                                    <input type="text" id="mqtt_ws_port" name="mqtt_ws_port" class="form-control" value="<? write(broker_ws_port === undefined ? '' : broker_ws_port); ?>" />
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label for="mqtt_wss_port" class="col-sm-3 control-label">WSS:// port</label>
+                                <div class="col-sm-9">
+                                    <input type="text" id="mqtt_wss_port" name="mqtt_wss_port" class="form-control" value="<? write(broker_wss_port === undefined ? '' : broker_wss_port); ?>" />
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <div class="col-sm-offset-3 col-sm-9">
+                                    <button class="btn btn-primary">Submit</button>
+                                </div>
+                            </div>
+                        </form>
+                    </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.
+                        </p>
+                    </div>
+                </div>
+            </div>
+        </div>
+<?
+        exit();
+    }
+?>
+
+
+
+<!-- A hidden-by-default div with a message shown when ws:// connections fail -->
+<!-- TODOZ Translate -->
+<div class="alert alert-danger" id="mqtt-ws-reconnecting-message" style="display: none;">
+    <p class="lead">
+        Looks like your browser is having troubles connecting to the MQTT server over ws://.  A few common causes:
+    </p>
+
+    <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 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>
+
+    <p>
+        Double-check your settings and try reloading this page.
+    </p>
+</div>
+
+<!-- A hidden-by-default div witha message sown when wss:// connections fail -->
+<!-- TODOZ Translate -->
+<div class="alert alert-danger" id="mqtt-wss-reconnecting-message" style="display: none;">
+    <p class="lead">
+        Looks like your browser is having troubles connecting to the MQTT server over wss://.  A few common causes:
+    </p>
+
+    <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 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>
+        <li>You're connecting to the broker via an IP address.</li>
+        <li>A firewall is blocking the connection from your current location.</li>
+    </ul>
+    
+    <p>
+        You can click the link below to try accessing the MQTT broker in a new tab/window, which might shed some light on what is going wrong, 
+        and the in the case of an untrusted cert, allow you to accept it (e.g. by clicking Proceed in Chrome):<br />
+        <a href="" id="mqtt-wss-reconnecting-link" target="_blank">Access the MQTT server</a>
+    </p>
+</div>
+
+
+
+<script src="https://unpkg.com/mqtt@5.0.2/dist/mqtt.min.js"></script>
+
+<script>
+    // Set javascript variables for system variables referenced by mqtt.js
+    var system_qwk_id = '<? write(system.qwk_id); ?>';
+    var broker_addr = '<? write(broker_addr); ?>';
+    var broker_password = '<? write(broker_password); ?>';
+    var broker_username = '<? write(broker_username); ?>';
+    var broker_ws_port = <? write(broker_ws_port); ?>;
+    var broker_wss_port = <? write(broker_wss_port); ?>;
+</script>
+<script src="./js/mqtt.js"></script>
diff --git a/webv4/pages/More/001-nodespy.xjs b/webv4/pages/More/001-nodespy.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..fd85ec66142abe7948bdb8d5d6dd1e1e50bd13b9
--- /dev/null
+++ b/webv4/pages/More/001-nodespy.xjs
@@ -0,0 +1,52 @@
+<!--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 fTelnet-related files
+    load(settings.web_lib + 'ftelnet.js');
+    load('ftelnethelper.js');
+?>
+
+<style>
+    .fTelnetStatusBar { display : none !important; }                       /* Don't show the status bar, we only want to use fTelnet for display purposes not connect purposes */
+    div.active { background-color: #ddd; padding-bottom: 15px; }           /* Highlight the div that has keyboard focus */
+    label.normal { font-weight: normal !important; margin-bottom: 0 !important; } /* Sanely format the label elements */
+    
+    /* Introduce a col-xl-6 to display two columns on large screens -- allows seeing all 4 of my nodes at once */
+    .col-xl-6 {
+        position: relative;
+        min-height: 1px;
+        padding-right: 15px;
+        padding-left: 15px;
+    }
+    @media (min-width: 1600px) {
+        .col-xl-6 { 
+            float: left;
+            width: 50%; 
+        }
+    }
+</style>
+
+<h1 align="center">Node Spy</h1>
+
+<? loadComponent('mqtt.xjs'); ?>
+
+<!-- Multiple fTelnet instances (one per node) will be added to this wrapper div -->
+<div id="ClientsWrapper"></div>
+
+<script id="fTelnetScript" src="<? write(get_url()); ?>"></script>
+<script src="./js/utf8_cp437.js"></script>
+
+<script>
+    // Set javascript variables for system variables referenced by nodespy.js
+    var system_nodes = <? write(system.nodes); ?>;
+    var system_qwk_id = '<? write(system.qwk_id); ?>';
+</script>
+<script src="./js/nodespy.js"></script>
diff --git a/webv4/pages/More/002-nodespy.xjs b/webv4/pages/More/002-nodespy.xjs
deleted file mode 100644
index 692a3c8a1cbbe3d91964b321a8a3aa643631bbbb..0000000000000000000000000000000000000000
--- a/webv4/pages/More/002-nodespy.xjs
+++ /dev/null
@@ -1,346 +0,0 @@
-<!--NO_SIDEBAR:Node Spy-->
-
-<?xjs
-    // 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 -->
-<?xjs    
-        exit();
-    }
-
-    // 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;
-    
-    // Abort if the mqtt broker is not enabled
-    if (!broker_enabled) {
-?>
-        <div class="alert alert-danger"><h3>You must enable the MQTT broker in SCFG -> Networks -> MQTT before you can use the node spy</h3></div> <!-- TODOX Translate -->
-<?xjs    
-        exit();
-    }
-    
-    // Abort if no ws(s) port set
-    if (!broker_ws_port || !broker_wss_port) {
-?>
-        <div class="alert alert-danger"><h3>You must specify the MQTT broker's WS (mqtt_ws_port) and WSS (mqtt_wss_port) ports in the [web] section of <?xjs write(system.ctrl_dir + 'modopts.ini'); ?> before you can use the node spy</h3></div> <!-- TODOX Translate -->
-<?xjs    
-        exit();
-    }
-
-    // Load fTelnet-related files
-    load(settings.web_lib + 'ftelnet.js');
-    load('ftelnethelper.js');
-?>
-
-<style>
-    .fTelnetStatusBar { display : none !important; }                       /* Don't show the status bar, we only want to use fTelnet for display purposes not connect purposes */
-    div.active { background-color: #ddd; padding-bottom: 15px; }           /* Highlight the div that has keyboard focus */
-    label { font-weight: normal !important; margin-bottom: 0 !important; } /* Sanely format the label elements */
-    
-    /* Introduce a col-xl-6 to display two columns on large screens -- allows seeing all 4 of my nodes at once */
-    .col-xl-6 {
-        position: relative;
-        min-height: 1px;
-        padding-right: 15px;
-        padding-left: 15px;
-    }
-    @media (min-width: 1600px) {
-        .col-xl-6 { 
-            float: left;
-            width: 50%; 
-        }
-    }
-</style>
-
-<h1 align="center">Node Spy</h1>
-
-<!-- A hidden-by-default div with a link to access the MQTT server in the browser, to see if the cert is trusted -->
-<div id="HttpsMqttDiv" style="display: none;">
-    <!-- TODOX Translate -->
-    Looks like your browser is having troubles connecting to the MQTT server over WSS.  A few common causes:
-    <ul>
-        <li>The MQTT broker is offline</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>
-        <li>You're connecting to the broker via an IP address</li>
-    </ul>
-    You can click the link below to try accessing the MQTT broker in a new tab/window, which might shed some light on what is going wrong, and the in the case of an
-    untrusted cert, allow you to accept it (eg clicking Proceed in Chrome):<br />
-    <a href="" id="HttpsMqttLink" target="_blank">Access the MQTT server</a>
-</div>
-
-<!-- Multiple fTelnet instances (one per node) will be added to this wrapper div -->
-<div id="ClientsWrapper"></div>
-
-<script id="fTelnetScript" src="<?xjs write(get_url()); ?>"></script>
-<script src="https://unpkg.com/mqtt@5.0.2/dist/mqtt.min.js"></script>
-<script src="./js/utf8_cp437.js"></script>
-<script>
-    var charsets = [];
-    var fTelnetControls = [];
-    var mqttClient;
-    
-    // Check if we're targeting a specific node
-    const urlParams = new URLSearchParams(window.location.search);
-    const targetNode = parseInt(urlParams.get('node'), 10);
-
-    // Connect to the mqtt broker and subscribe to some topics    
-    function connect_to_mqtt_broker() {
-        const options = {
-          keepalive: 60,
-          clientId: '<?xjs write(system.qwk_id); ?>' + Math.random().toString(16).substr(2, 8),
-          username: '<?xjs write(broker_username); ?>',
-          password: '<?xjs write(broker_password); ?>',
-          protocolId: 'MQTT',
-          protocolVersion: 4,
-          clean: true,
-          reconnectPeriod: 5000,
-          connectTimeout: 30 * 1000,
-          will: {
-            topic: 'WillMsg',
-            payload: 'Connection Closed abnormally..!',
-            qos: 0,
-            retain: false
-          },
-        }
-
-        // Build the host string to connect to based on whether ws:// or wss:// is required
-        var protocol = location.protocol === 'https:' ? 'wss' : 'ws';
-        var port = location.protocol === 'https:' ? <?xjs write(broker_wss_port); ?> : <?xjs write(broker_ws_port); ?>;
-        const host = protocol + '://<?xjs write(broker_addr) ?>:' + port + '/mqtt';
-
-        // Set the debugging link to a modified version of the host string
-        $('#HttpsMqttLink').attr('href', host.replace('wss://', 'https://'));
-
-        // Connect to the mqtt broker
-        log_ftelnet('Connecting to mqtt broker (' + host + ')');
-        mqttClient = mqtt.connect(host, options);
-
-        // Subscribe to topics on connect
-        mqttClient.on('connect', () => {
-          log_ftelnet('Connected');
-          $('#HttpsMqttDiv').hide();
-
-          for (var node in fTelnetControls) {
-            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '', { qos: 2 });
-            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/output', { qos: 2 });
-            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/terminal', { qos: 2 });
-          }
-        });
-
-        // Display an error when connection fails
-        mqttClient.on('error', (err) => {
-          log_ftelnet('Connection error: ', err);
-          mqttClient.end();
-        });
-        
-        // Handle messages for subscribed topics
-        mqttClient.on('message', (topic, message, packet) => {
-            var match = topic.match(/node[\/](\d*)([\/](output|terminal))?/);
-            var node = match[1];
-            var messageType = match[2];
-            
-            switch (messageType) {
-                case undefined:
-                    // message contains the node's current activity (ie waiting for caller, or user is running an external, etc)
-                    $('#Status' + node).html(message.toString());
-                    break;
-              
-                case '/output':
-                    // message contains the raw output being sent to the user, so write it to the fTelnet instance
-                    var buf = new Uint8Array(message);
-
-                    var str = '';
-                    for (var i = 0; i < message.length; i++) {
-                        str += String.fromCharCode(buf[i]);
-                    }
-
-                    // UTF-8 decode, if necessary
-                    if (charsets[node] === 'UTF-8') {
-                        str = utf8_cp437(str);
-                    }
-
-                    fTelnetControls[node]._Ansi.Write(str);
-                    break;
-              
-                case '/terminal':
-                    // message contains tab-separated terminal information, most importantly the column and row count as the first
-                    // and second elements, so resize fTelnet and tell it to reload the best font based on the new size.
-                    var arr = message.toString().split('\t');
-                    fTelnetControls[node]._Crt.SetScreenSize(parseInt(arr[0], 10), parseInt(arr[1], 10));
-                    fTelnetControls[node]._Crt.SetFont(fTelnetControls[node]._Crt.Font.Name); 
-                    charsets[node] = arr[4];
-                    // TODOX When arr[4] is 'CBM-ASCII' things get messy -- did I add support for PETSCII to fTelnet?
-                    break;
-            }
-        });
-        
-        // Display a message when the connection drops and the client is attempting to reconnect
-        // Also show the debugging message if https is being used, because the sysop may need to resolve issues with their ssl cert
-        mqttClient.on('reconnect', () => {
-            log_ftelnet('Reconnecting...');
-          
-            if (location.protocol === 'https:') {
-                $('#HttpsMqttDiv').show();
-            }
-        });
-    }
-    
-    // Create an fTelnet instance for each node
-    function create_ftelnet_instances() {
-        // Loop through the node range
-        for (var node = 1; node <= <?xjs write(system.nodes) ?>; node++) {
-            if (!targetNode || (targetNode === node)) {
-                // Build a node-specific link
-                var nodeUrl = location.href;
-                if (!nodeUrl.includes("&node=")) {
-                  nodeUrl += '&node=' + node;
-                }
-                
-                // Build a div containing the node status, keyboard checkbox, and fTelnet instance
-                $('#ClientsWrapper').append(`
-                  <div id="Client${node}" class="${targetNode ? '' : 'col-xl-6'}">
-                    <h4 style="text-align: center;">
-                      <a href="${nodeUrl}">Node ${node}</a> - 
-                      <span id="Status${node}">Status Unknown</span> &nbsp; &nbsp; 
-                      <span style="white-space: nowrap;">
-                        <input type="checkbox" class="keyboard-input" id="chkKeyboard${node}" value="${node}" /> 
-                        <label for="chkKeyboard${node}">Enable Keyboard Input</label>
-                      </span>
-                    </h4>
-                    <div id="fTelnetContainer${node}" class="fTelnetContainer"></div>
-                  </div>
-                `);
-
-                // And initialize a new fTelnet instance
-                var Options = new fTelnetOptions();
-                Options.AllowModernScrollback = false;
-                Options.BareLFtoCRLF = false;
-                Options.BitsPerSecond = 57600;
-                Options.ConnectionType = 'telnet';
-                Options.Emulation = 'ansi-bbs';
-                Options.Enter = '\r';
-                Options.Font = 'CP437';
-                Options.ForceWss = false;
-                Options.Hostname = 'unused';
-                Options.LocalEcho = false;
-                Options.Port = 11235;
-                Options.ScreenColumns = 80;
-                Options.ScreenRows = 25;
-                Options.SplashScreen = ' ';
-                fTelnetControls[node] = new fTelnetClient('fTelnetContainer' + node, Options);
-                charsets[node] = 'CP437';
-            }
-        }
-    }
-
-    // Setup the keyboard handler
-    function init_keyboard_handler() {
-        // Handle clicks on the keyboard checkboxes
-        $('.keyboard-input').click(function() {
-            var selectedNode = this.value;
-            
-            // Highlight the new selected div, and un-highlight+un-check the other divs and checkboxes
-            for (var node in fTelnetControls) {
-                if ((node === selectedNode) && this.checked) {
-                    $('#Client' + node).addClass('active');
-                } else {
-                    $('#Client' + node).removeClass('active');
-                    $('.keyboard-input[value=' + node + ']').removeAttr('checked');
-                }
-            }
-        });
-        
-        // Handle keydown event, which is where control keys and special keys are handled
-        // Code is copy/pasted (with slight modifications) from fTelnet
-        window.addEventListener('keydown', function (ke) {
-            var node = $('.keyboard-input:checked').attr('value');
-            if (node) {
-                var keyString = '';
-            
-                if (ke.ctrlKey) {
-                    // Handle control + letter keys
-                    if ((ke.keyCode >= 65) && (ke.keyCode <= 90)) {
-                        keyString = String.fromCharCode(ke.keyCode - 64);
-                    } else if ((ke.keyCode >= 97) && (ke.keyCode <= 122)) {
-                        keyString = String.fromCharCode(ke.keyCode - 96);
-                    }
-                } else {
-                    switch (ke.keyCode) {
-                        // Handle special keys
-                        case KeyboardKeys.BACKSPACE: keyString = '\b'; break;
-                        case KeyboardKeys.DELETE: keyString = '\x7F'; break;
-                        case KeyboardKeys.DOWN: keyString = '\x1B[B'; break;
-                        case KeyboardKeys.END: keyString = '\x1B[K'; break;
-                        case KeyboardKeys.ENTER: keyString = '\r\n'; break;
-                        case KeyboardKeys.ESCAPE: keyString = '\x1B'; break;
-                        case KeyboardKeys.F1: keyString = '\x1BOP'; break;
-                        case KeyboardKeys.F2: keyString = '\x1BOQ'; break;
-                        case KeyboardKeys.F3: keyString = '\x1BOR'; break;
-                        case KeyboardKeys.F4: keyString = '\x1BOS'; break;
-                        case KeyboardKeys.F5: keyString = '\x1BOt'; break;
-                        case KeyboardKeys.F6: keyString = '\x1B[17~'; break;
-                        case KeyboardKeys.F7: keyString = '\x1B[18~'; break;
-                        case KeyboardKeys.F8: keyString = '\x1B[19~'; break;
-                        case KeyboardKeys.F9: keyString = '\x1B[20~'; break;
-                        case KeyboardKeys.F10: keyString = '\x1B[21~'; break;
-                        case KeyboardKeys.F11: keyString = '\x1B[23~'; break;
-                        case KeyboardKeys.F12: keyString = '\x1B[24~'; break;
-                        case KeyboardKeys.HOME: keyString = '\x1B[H'; break;
-                        case KeyboardKeys.INSERT: keyString = '\x1B@'; break;
-                        case KeyboardKeys.LEFT: keyString = '\x1B[D'; break;
-                        case KeyboardKeys.PAGE_DOWN: keyString = '\x1B[U'; break;
-                        case KeyboardKeys.PAGE_UP: keyString = '\x1B[V'; break;
-                        case KeyboardKeys.RIGHT: keyString = '\x1B[C'; break;
-                        case KeyboardKeys.SPACE: keyString = ' '; break;
-                        case KeyboardKeys.TAB: keyString = '\t'; break;
-                        case KeyboardKeys.UP: keyString = '\x1B[A'; break;
-                    }
-                }
-
-                // If we have a keyString, then publish it so sbbs sees it as input
-                if (keyString) {
-                    mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', keyString, { qos: 1, retain: false });
-                }
-
-                // If we have a keyString, or CTRL is being held, prevent further handling (so the keypress event won't be hit next)
-                if ((keyString) || (ke.ctrlKey)) {
-                    ke.preventDefault();
-                }
-            }
-        });
-        
-        // Handle keypress event, which is where standard keys are handled
-        // Code is copy/pasted (with slight modifications) from fTelnet
-        window.addEventListener('keypress', function (ke) {
-            var node = $('.keyboard-input:checked').attr('value');
-            if (node) {
-                if (ke.charCode >= 33) {
-                    mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', String.fromCharCode(ke.charCode), { qos: 1, retain: false });
-                }
-            }
-        });
-    }
-    
-    // Log a message to each of the fTelnet instances
-    function log_ftelnet(message) {
-        for (var node in fTelnetControls) {
-            fTelnetControls[node]._Crt.WriteLn(message);
-        }
-    }
-
-    // Add an onload handler to setup the node spy
-    window.addEventListener('load', (event) => {
-        create_ftelnet_instances();
-        init_keyboard_handler();
-        connect_to_mqtt_broker();
-    });
-</script>
\ No newline at end of file
diff --git a/webv4/root/js/mqtt.js b/webv4/root/js/mqtt.js
new file mode 100644
index 0000000000000000000000000000000000000000..51d77b3ffd866760a05da60f274482807dbe89a4
--- /dev/null
+++ b/webv4/root/js/mqtt.js
@@ -0,0 +1,73 @@
+var mqtt_client;
+
+// Connect to the mqtt broker and subscribe to some topics    
+function mqtt_connect(topics, message_callback, log_callback) {
+    const options = {
+        keepalive: 60,
+        clientId: system_qwk_id + Math.random().toString(16).substr(2, 8),
+        username: broker_username,
+        password: broker_password,
+        protocolId: 'MQTT',
+        protocolVersion: 4,
+        clean: true,
+        reconnectPeriod: 5000,
+        connectTimeout: 30 * 1000,
+        will: {
+            topic: 'WillMsg',
+            payload: 'Connection Closed abnormally..!',
+            qos: 0,
+            retain: false
+        },
+    };
+
+    // Build the host string to connect to based on whether ws:// or wss:// is required
+    var protocol = location.protocol === 'https:' ? 'wss' : 'ws';
+    var port = location.protocol === 'https:' ? broker_wss_port : broker_ws_port;
+    const host = protocol + '://' + broker_addr + ':' + port + '/mqtt';
+
+    // Set the debugging link to a modified version of the host string
+    $('#mqtt-wss-reconnecting-link').attr('href', host.replace('wss://', 'https://'));
+
+    // Connect to the mqtt broker
+    log_callback('Connecting to mqtt broker (' + host + ')');
+    mqtt_client = mqtt.connect(host, options);
+
+    // Subscribe to topics on connect
+    mqtt_client.on('connect', () => {
+        log_callback('Connected');
+        
+        for (var i = 0; i < topics.length; i++) {
+            mqtt_client.subscribe(topics[i], { qos: 2 });
+        }
+
+        $('#mqtt-ws-reconnecting-message').hide();
+        $('#mqtt-wss-reconnecting-message').hide();
+    });
+
+    // Display an error when connection fails
+    mqtt_client.on('error', (err) => {
+        log_callback('Connection error: ', err);
+        mqtt_client.end();
+    });
+    
+    // Handle messages for subscribed topics
+    mqtt_client.on('message', (topic, message, packet) => {
+        message_callback(topic, message, packet);
+    });
+    
+    // Display a message when the connection drops and the client is attempting to reconnect
+    // Also show the debugging message if https is being used, because the sysop may need to resolve issues with their ssl cert
+    mqtt_client.on('reconnect', () => {
+        log_callback('Reconnecting...');
+        
+        if (location.protocol === 'https:') {
+            $('#mqtt-wss-reconnecting-message').show();
+        } else {
+            $('#mqtt-ws-reconnecting-message').show();
+        }
+    });
+}    
+
+function mqtt_publish(topic, value) {
+    mqtt_client.publish(topic, value, { qos: 1, retain: false });
+}
diff --git a/webv4/root/js/nodespy.js b/webv4/root/js/nodespy.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f85bae8029022c7d7903bbc3717eb0071a9f847
--- /dev/null
+++ b/webv4/root/js/nodespy.js
@@ -0,0 +1,204 @@
+var spy_nodes = [];
+
+// Check if we're targeting a specific node
+const urlParams = new URLSearchParams(window.location.search);
+const targetNode = parseInt(urlParams.get('node'), 10);
+
+// Create an fTelnet instance for each node
+function create_ftelnet_instances() {
+    // Loop through the node range
+    for (var node = 1; node <= system_nodes; node++) {
+        if (!targetNode || (targetNode === node)) {
+            // Build a node-specific link
+            var nodeUrl = location.href;
+            if (!nodeUrl.includes("&node=")) {
+                nodeUrl += '&node=' + node;
+            }
+            
+            // Build a div containing the node status, keyboard checkbox, and fTelnet instance
+            $('#ClientsWrapper').append(`
+                <div id="Client${node}" class="${targetNode ? '' : 'col-xl-6'}">
+                <h4 style="text-align: center;">
+                    <a href="${nodeUrl}">Node ${node}</a> - 
+                    <span id="Status${node}">Status Unknown</span> &nbsp; &nbsp; 
+                    <span style="white-space: nowrap;">
+                    <input type="checkbox" class="keyboard-input" id="chkKeyboard${node}" value="${node}" /> 
+                    <label class="normal" for="chkKeyboard${node}">Enable Keyboard Input</label>
+                    </span>
+                </h4>
+                <div id="fTelnetContainer${node}" class="fTelnetContainer"></div>
+                </div>
+            `);
+
+            // And initialize a new fTelnet instance
+            var Options = new fTelnetOptions();
+            Options.AllowModernScrollback = false;
+            Options.BareLFtoCRLF = false;
+            Options.BitsPerSecond = 57600;
+            Options.ConnectionType = 'telnet';
+            Options.Emulation = 'ansi-bbs';
+            Options.Enter = '\r';
+            Options.Font = 'CP437';
+            Options.ForceWss = false;
+            Options.Hostname = 'unused';
+            Options.LocalEcho = false;
+            Options.Port = 11235;
+            Options.ScreenColumns = 80;
+            Options.ScreenRows = 25;
+            Options.SplashScreen = ' ';
+            spy_nodes[node] = {
+                charset: 'CP437',
+                ftelnet: new fTelnetClient('fTelnetContainer' + node, Options),
+            };
+        }
+    }
+}
+
+// Setup the keyboard handler
+function init_keyboard_handler() {
+    // Handle clicks on the keyboard checkboxes
+    $('.keyboard-input').click(function() {
+        var selectedNode = this.value;
+        
+        // Highlight the new selected div, and un-highlight+un-check the other divs and checkboxes
+        for (var node in spy_nodes) {
+            if ((node === selectedNode) && this.checked) {
+                $('#Client' + node).addClass('active');
+            } else {
+                $('#Client' + node).removeClass('active');
+                $('.keyboard-input[value=' + node + ']').removeAttr('checked');
+            }
+        }
+    });
+    
+    // Handle keydown event, which is where control keys and special keys are handled
+    // Code is copy/pasted (with slight modifications) from fTelnet
+    window.addEventListener('keydown', function (ke) {
+        var node = $('.keyboard-input:checked').attr('value');
+        if (node) {
+            var keyString = '';
+        
+            if (ke.ctrlKey) {
+                // Handle control + letter keys
+                if ((ke.keyCode >= 65) && (ke.keyCode <= 90)) {
+                    keyString = String.fromCharCode(ke.keyCode - 64);
+                } else if ((ke.keyCode >= 97) && (ke.keyCode <= 122)) {
+                    keyString = String.fromCharCode(ke.keyCode - 96);
+                }
+            } else {
+                switch (ke.keyCode) {
+                    // Handle special keys
+                    case KeyboardKeys.BACKSPACE: keyString = '\b'; break;
+                    case KeyboardKeys.DELETE: keyString = '\x7F'; break;
+                    case KeyboardKeys.DOWN: keyString = '\x1B[B'; break;
+                    case KeyboardKeys.END: keyString = '\x1B[K'; break;
+                    case KeyboardKeys.ENTER: keyString = '\r\n'; break;
+                    case KeyboardKeys.ESCAPE: keyString = '\x1B'; break;
+                    case KeyboardKeys.F1: keyString = '\x1BOP'; break;
+                    case KeyboardKeys.F2: keyString = '\x1BOQ'; break;
+                    case KeyboardKeys.F3: keyString = '\x1BOR'; break;
+                    case KeyboardKeys.F4: keyString = '\x1BOS'; break;
+                    case KeyboardKeys.F5: keyString = '\x1BOt'; break;
+                    case KeyboardKeys.F6: keyString = '\x1B[17~'; break;
+                    case KeyboardKeys.F7: keyString = '\x1B[18~'; break;
+                    case KeyboardKeys.F8: keyString = '\x1B[19~'; break;
+                    case KeyboardKeys.F9: keyString = '\x1B[20~'; break;
+                    case KeyboardKeys.F10: keyString = '\x1B[21~'; break;
+                    case KeyboardKeys.F11: keyString = '\x1B[23~'; break;
+                    case KeyboardKeys.F12: keyString = '\x1B[24~'; break;
+                    case KeyboardKeys.HOME: keyString = '\x1B[H'; break;
+                    case KeyboardKeys.INSERT: keyString = '\x1B@'; break;
+                    case KeyboardKeys.LEFT: keyString = '\x1B[D'; break;
+                    case KeyboardKeys.PAGE_DOWN: keyString = '\x1B[U'; break;
+                    case KeyboardKeys.PAGE_UP: keyString = '\x1B[V'; break;
+                    case KeyboardKeys.RIGHT: keyString = '\x1B[C'; break;
+                    case KeyboardKeys.SPACE: keyString = ' '; break;
+                    case KeyboardKeys.TAB: keyString = '\t'; break;
+                    case KeyboardKeys.UP: keyString = '\x1B[A'; break;
+                }
+            }
+
+            // If we have a keyString, then publish it so sbbs sees it as input
+            if (keyString) {
+                mqtt_publish('sbbs/' + system_qwk_id + '/node/' + node + '/input', keyString);
+            }
+
+            // If we have a keyString, or CTRL is being held, prevent further handling (so the keypress event won't be hit next)
+            if ((keyString) || (ke.ctrlKey)) {
+                ke.preventDefault();
+            }
+        }
+    });
+    
+    // Handle keypress event, which is where standard keys are handled
+    // Code is copy/pasted (with slight modifications) from fTelnet
+    window.addEventListener('keypress', function (ke) {
+        var node = $('.keyboard-input:checked').attr('value');
+        if (node) {
+            if (ke.charCode >= 33) {
+                mqtt_publish('sbbs/' + system_qwk_id + '/node/' + node + '/input', String.fromCharCode(ke.charCode));
+            }
+        }
+    });
+}
+
+// Log a message to each of the fTelnet instances
+function log_ftelnet(message) {
+    for (var node in spy_nodes) {
+        spy_nodes[node].ftelnet._Crt.WriteLn(message);
+    }
+}
+
+function mqtt_message(topic, message, packet) {
+    var match = topic.match(/node[\/](\d*)([\/](output|terminal))?/);
+    var node = match[1];
+    var messageType = match[2];
+    
+    switch (messageType) {
+        case undefined:
+            // message contains the node's current activity (ie waiting for caller, or user is running an external, etc)
+            $('#Status' + node).html(message.toString());
+            break;
+        
+        case '/output':
+            // message contains the raw output being sent to the user, so write it to the fTelnet instance
+            var buf = new Uint8Array(message);
+
+            var str = '';
+            for (var i = 0; i < message.length; i++) {
+                str += String.fromCharCode(buf[i]);
+            }
+
+            // UTF-8 decode, if necessary
+            if (spy_nodes[node].charset === 'UTF-8') {
+                str = utf8_cp437(str);
+            }
+
+            spy_nodes[node].ftelnet._Ansi.Write(str);
+            break;
+        
+        case '/terminal':
+            // message contains tab-separated terminal information, most importantly the column and row count as the first
+            // and second elements, so resize fTelnet and tell it to reload the best font based on the new size.
+            var arr = message.toString().split('\t');
+            spy_nodes[node].ftelnet._Crt.SetScreenSize(parseInt(arr[0], 10), parseInt(arr[1], 10));
+            spy_nodes[node].ftelnet._Crt.SetFont(spy_nodes[node].ftelnet._Crt.Font.Name); 
+            spy_nodes[node].charset = arr[4];
+            // TODOX When arr[4] is 'CBM-ASCII' things get messy -- did I add support for PETSCII to fTelnet?
+            break;
+    }        
+}
+
+// Add an onload handler to setup the node spy
+window.addEventListener('load', (event) => {
+    create_ftelnet_instances();
+    init_keyboard_handler();
+    
+    var topics = [];
+    for (var node in spy_nodes) {
+        topics.push('sbbs/' + system_qwk_id + '/node/' + node + '');
+        topics.push('sbbs/' + system_qwk_id + '/node/' + node + '/output');
+        topics.push('sbbs/' + system_qwk_id + '/node/' + node + '/terminal');
+    }
+    mqtt_connect(topics, mqtt_message, log_ftelnet);
+});