VYPR
Medium severity6.3NVD Advisory· Published Jun 3, 2026· Updated Jun 3, 2026

malla: Stored XSS via Meshtastic node names in multiple frontend pages

CVE-2026-43980

Description

Node names (long_name, short_name) received via MQTT are stored in SQLite without sanitization and rendered into the DOM without escaping. Any participant on a public Meshtastic MQTT broker can set a malicious node name that executes JavaScript in the browser of every Malla dashboard visitor.

Affected files:

  • src/malla/templates/traceroute_graph.html (line ~832)
  • src/malla/templates/map.html (lines ~945, 1078)
  • src/malla/templates/packet_detail.html (lines ~1402, 1452)
  • src/malla/static/js/relay_node_analysis.js (line ~124)

Steps to reproduce

  1. Publish a Meshtastic NODEINFO_APP packet to any public MQTT broker with long_name set to a HTML entity i.e ``
  2. Wait for malla-capture to store it
  3. Open the dashboard

Impact

Allows unauthenticated remote attackers to execute arbitrary JavaScript in the browser, such as:

  • Phishing overlays
  • Force redirect to malicious websites
  • Injection of arbitrary third-party scripts (no CSP restrictions)
  • Browser resource abuse
  • Persistent dashboard denial of service

Affected products

2

Patches

1
4086e2b5f616

Frontend fixes (#77)

https://github.com/zenitraM/mallaAlejandro MartínezApr 23, 2026via ghsa-ref
24 files changed · +1989 1520
  • src/malla/static/js/chat.js+152 80 modified
    @@ -70,8 +70,6 @@
             return d.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
         }
     
    -    function esc(s) { var e = document.createElement('span'); e.textContent = s; return e.innerHTML; }
    -
         function nodeName(id) { var n = nodeCache[String(id)]; return n ? n.name : ('!' + (id >>> 0).toString(16).padStart(8, '0')); }
         function nodeShort(id) { var n = nodeCache[String(id)]; return n ? (n.short || n.name) : ''; }
     
    @@ -180,18 +178,40 @@
             }
         }
     
    +    function nodeAnchor(nodeId, label, packetId, extraClass) {
    +        if (!nodeId) return textNode(label);
    +        var link = el('a', {
    +            href: safePath('/node/' + nodeId),
    +            className: (extraClass ? extraClass + ' ' : '') + 'node-link',
    +            title: nodeName(nodeId),
    +            dataset: {
    +                nodeId: nodeId,
    +                tooltipHideId: 1,
    +                bsToggle: 'tooltip',
    +                bsPlacement: 'top',
    +                bsHtml: 'true',
    +                bsTitle: 'Loading...'
    +            }
    +        }, label);
    +        if (packetId) link.dataset.packetId = packetId;
    +        return link;
    +    }
    +
         function renderNodeLink(nodeId, label, packetId) {
    -        return nodeId
    -            ? '<span class="' + nickColor(nodeId) + '"><a href="/node/' + nodeId + '" class="rx-pop-link rx-pop-node node-link" data-node-id="' + nodeId + '" data-tooltip-hide-id="1"' + (packetId ? ' data-packet-id="' + packetId + '"' : '') + ' data-bs-toggle="tooltip" data-bs-placement="top" data-bs-html="true" data-bs-title="Loading...">' + label + '</a></span>'
    -            : label;
    +        if (!nodeId) return textNode(label);
    +        return el('span', { className: nickColor(nodeId) }, nodeAnchor(nodeId, label, packetId, 'rx-pop-link rx-pop-node'));
         }
     
         function renderReceptionLink(packetId) {
    -        return '<a href="/packet/' + packetId + '" class="rx-pop-link rx-pop-packet-link" title="Open packet reception"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i><span class="visually-hidden">Open packet reception</span></a>';
    +        return el('a', {
    +            href: safePath('/packet/' + packetId),
    +            className: 'rx-pop-link rx-pop-packet-link',
    +            title: 'Open packet reception'
    +        }, icon('bi bi-box-arrow-up-right'), el('span', { className: 'visually-hidden' }, 'Open packet reception'));
         }
     
         function renderRelayCand(c, packetId) {
    -        var label = esc(c.short || c.name || ('!' + Number(c.id || 0).toString(16).padStart(8, '0')));
    +        var label = c.short || c.name || ('!' + Number(c.id || 0).toString(16).padStart(8, '0'));
             return renderNodeLink(c.id, label, packetId);
         }
     
    @@ -204,11 +224,26 @@
         }
     
         function linkify(text) {
    -        return String(text).split(/(https?:\/\/[^\s<]+)/g).map(function (part) {
    -            if (!/^https?:\/\//.test(part)) return esc(part);
    +        var frag = document.createDocumentFragment();
    +        String(text).split(/(https?:\/\/[^\s<]+)/g).forEach(function (part) {
    +            if (!/^https?:\/\//.test(part)) {
    +                frag.appendChild(textNode(part));
    +                return;
    +            }
    +            var safeHref = safeUrl(part, { allowRelative: false, allowedProtocols: ['http:', 'https:'] });
    +            if (!safeHref) {
    +                frag.appendChild(textNode(part));
    +                return;
    +            }
                 var display = part.length > 50 ? part.substring(0, 47) + '…' : part;
    -            return '<a href="' + esc(part) + '" target="_blank" rel="noopener" class="chat-link">' + esc(display) + '</a>';
    -        }).join('');
    +            frag.appendChild(el('a', {
    +                href: safeHref,
    +                target: '_blank',
    +                rel: 'noopener',
    +                className: 'chat-link'
    +            }, display));
    +        });
    +        return frag;
         }
     
         function dayKey(ts) { var d = new Date(ts * 1000); return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); }
    @@ -261,38 +296,56 @@
         function closePop() { if (activePop) { activePop.el.remove(); activePop = null; } }
     
         function popContent(msg) {
    -        var sorted = sortRx(msg.rxList), rows = '';
    +        var sorted = sortRx(msg.rxList);
    +        var tbody = el('tbody');
             for (var k = 0; k < sorted.length; k++) {
                 var rx = sorted[k];
    -            var gn = esc(gwName(rx.gw)), gid = gwNid(rx.gw);
    +            var gn = gwName(rx.gw), gid = gwNid(rx.gw);
                 var gc = renderNodeLink(gid, gn, rx.id);
    -            var rc = '';
    +            var rc = textNode('');
                 if (rx.rl) {
                     var sfx = relaySfx(rx.rl), key = relayFilterKey(rx), cands = relayCandidatesForRx(rx);
                     var relayLoading = key && !Object.prototype.hasOwnProperty.call(relayFilterCache, key) && (relayFilterPending[key] || relayFilterQueued[key]) && relayCands(rx.rl).length > 1;
                     if (cands.length === 1) {
    -                    rc = '<span class="rx-relay-match" title="' + esc(cands[0].name) + ' (0x' + sfx + ')">' + renderRelayCand(cands[0], rx.id) + '</span>';
    +                    rc = el('span', { className: 'rx-relay-match', title: (cands[0].name || '') + ' (0x' + sfx + ')' }, renderRelayCand(cands[0], rx.id));
                     } else if (relayLoading) {
    -                    rc = '<span class="rx-relay-loading" title="Resolving relay candidates for 0x' + esc(sfx) + '">' + sfx + ' <small>loading</small></span>';
    +                    rc = el('span', { className: 'rx-relay-loading', title: 'Resolving relay candidates for 0x' + sfx }, sfx, ' ', el('small', 'loading'));
                     } else if (cands.length > 1) {
                         var titleNames = cands.map(function (c) { return c.short || c.name; }).join(', ');
                         if (cands.length <= 2) {
    -                        rc = '<span class="rx-relay-ambig" title="' + esc('0x' + sfx + ': ' + titleNames) + '">' + cands.map(function (c) { return renderRelayCand(c, rx.id); }).join(', ') + '</span>';
    +                        rc = el('span', { className: 'rx-relay-ambig', title: '0x' + sfx + ': ' + titleNames });
    +                        cands.forEach(function (c, index) {
    +                            if (index > 0) rc.appendChild(textNode(', '));
    +                            rc.appendChild(renderRelayCand(c, rx.id));
    +                        });
                         } else {
    -                        var first = renderRelayCand(cands[0], rx.id);
    -                        rc = '<span class="rx-relay-ambig" title="' + esc('0x' + sfx + ': ' + titleNames) + '">' + first + ' <small>(+' + (cands.length - 1) + ')</small></span>';
    +                        rc = el('span', { className: 'rx-relay-ambig', title: '0x' + sfx + ': ' + titleNames }, renderRelayCand(cands[0], rx.id), ' ', el('small', '(+' + (cands.length - 1) + ')'));
                         }
    -                } else { rc = sfx; }
    +                } else { rc = textNode(sfx); }
                 }
    -            rows += '<tr><td class="rx-col-pkt">' + renderReceptionLink(rx.id) + '</td>' +
    -                '<td class="rx-col-gw">' + gc + '</td>' +
    -                '<td class="rx-col-num">' + (rx.hops != null ? rx.hops : '?') + '</td>' +
    -                '<td class="rx-col-num">' + (rx.rs != null ? rx.rs : '') + '</td>' +
    -                '<td class="rx-col-num">' + (rx.sn != null ? (typeof rx.sn === 'number' ? rx.sn.toFixed(1) : rx.sn) : '') + '</td>' +
    -                '<td class="rx-col-relay">' + rc + '</td></tr>';
    +            tbody.appendChild(el('tr',
    +                el('td', { className: 'rx-col-pkt' }, renderReceptionLink(rx.id)),
    +                el('td', { className: 'rx-col-gw' }, gc),
    +                el('td', { className: 'rx-col-num' }, rx.hops != null ? String(rx.hops) : '?'),
    +                el('td', { className: 'rx-col-num' }, rx.rs != null ? String(rx.rs) : ''),
    +                el('td', { className: 'rx-col-num' }, rx.sn != null ? (typeof rx.sn === 'number' ? rx.sn.toFixed(1) : String(rx.sn)) : ''),
    +                el('td', { className: 'rx-col-relay' }, rc)
    +            ));
             }
    -        return '<div class="rx-pop-header">' + sorted.length + ' reception' + (sorted.length > 1 ? 's' : '') + '</div>' +
    -            '<table class="rx-pop-table"><thead><tr><th class="rx-col-pkt">Pkt</th><th class="rx-col-gw">Gateway</th><th class="rx-col-num">Hops</th><th class="rx-col-num">RSSI</th><th class="rx-col-num">SNR</th><th class="rx-col-relay">Relay</th></tr></thead><tbody>' + rows + '</tbody></table>';
    +        return fragment(
    +            el('div', { className: 'rx-pop-header' }, sorted.length + ' reception' + (sorted.length > 1 ? 's' : '')),
    +            el('table', { className: 'rx-pop-table' },
    +                el('thead', el('tr',
    +                    el('th', { className: 'rx-col-pkt' }, 'Pkt'),
    +                    el('th', { className: 'rx-col-gw' }, 'Gateway'),
    +                    el('th', { className: 'rx-col-num' }, 'Hops'),
    +                    el('th', { className: 'rx-col-num' }, 'RSSI'),
    +                    el('th', { className: 'rx-col-num' }, 'SNR'),
    +                    el('th', { className: 'rx-col-relay' }, 'Relay')
    +                )),
    +                tbody
    +            )
    +        );
         }
     
         function posPop(el, badge) {
    @@ -328,7 +381,7 @@
             closePop();
             var pop = document.createElement('div');
             pop.className = 'rx-popover' + (pinned ? ' rx-pop-pinned' : '');
    -        pop.innerHTML = popContent(msg);
    +        setChildren(pop, popContent(msg));
             pop.addEventListener('mousedown', function (e) { e.stopPropagation(); });
             activePop = { el: pop, badge: badge, meshId: meshId, pinned: pinned };
             posPop(pop, badge);
    @@ -339,7 +392,7 @@
             if (!activePop || activePop.meshId !== meshId) return;
             var msg = messagesByMesh.get(meshId);
             if (!msg) return;
    -        activePop.el.innerHTML = popContent(msg);
    +        setChildren(activePop.el, popContent(msg));
             initPopTips(activePop.el);
         }
     
    @@ -352,30 +405,48 @@
         // ----- reply helper -----
     
         function replySnippet(msg) {
    -        if (!msg.replyId) return '';
    +        if (!msg.replyId) return null;
             var ref = resolveReplyTarget(msg.replyId);
             var label;
             if (ref) {
    -            var sender = esc(nodeShort(ref.fromId) || nodeName(ref.fromId));
    +            var sender = nodeShort(ref.fromId) || nodeName(ref.fromId);
                 var snip = ref.text.length > 50 ? ref.text.substring(0, 47) + '…' : ref.text;
    -            label = sender + ': ' + esc(snip);
    +            label = sender + ': ' + snip;
             } else { label = 'msg #' + msg.replyId; }
             var href = ref ? '#msg-' + (ref.meshId || ref.firstId) : '#';
    -        return '<span class="chat-reply"><i class="bi bi-reply"></i> <a href="' + href + '" class="chat-reply-link" data-reply-mesh="' + msg.replyId + '">' + label + '</a></span>';
    +        return el('span', { className: 'chat-reply' },
    +            icon('bi bi-reply'),
    +            ' ',
    +            el('a', {
    +                href: href,
    +                className: 'chat-reply-link',
    +                dataset: { replyMesh: msg.replyId }
    +            }, label)
    +        );
         }
     
    -    function packetLinkHtml(msg) {
    -        return '<a href="/packet/' + msg.firstId + '" class="chat-packet-link" title="Open packet details (Malla ID#' + msg.firstId + ')"><i class="bi bi-box-arrow-up-right"></i><span></span></a>';
    +    function packetLinkNode(msg) {
    +        return el('a', {
    +            href: safePath('/packet/' + msg.firstId),
    +            className: 'chat-packet-link',
    +            title: 'Open packet details (Malla ID#' + msg.firstId + ')'
    +        }, icon('bi bi-box-arrow-up-right'), el('span'));
         }
     
         // ----- rendering -----
     
    -    function metaHtml(msg) {
    -        return '<span class="chat-meta">' +
    -            '<span class="chat-rx-badge badge bg-secondary" data-mesh-id="' + (msg.meshId || msg.firstId) + '">rx' + msg.rxList.length + ' h' + hopsLabel(msg) + '</span>' +
    -            (msg.channel ? '<span class="chat-channel">' + esc(msg.channel) + '</span>' : '') +
    -            packetLinkHtml(msg) +
    -        '</span>';
    +    function metaNode(msg) {
    +        var meta = el('span', { className: 'chat-meta' },
    +            el('span', {
    +                className: 'chat-rx-badge badge bg-secondary',
    +                dataset: { meshId: msg.meshId || msg.firstId }
    +            }, 'rx' + msg.rxList.length + ' h' + hopsLabel(msg))
    +        );
    +        if (msg.channel) {
    +            meta.appendChild(el('span', { className: 'chat-channel' }, msg.channel));
    +        }
    +        meta.appendChild(packetLinkNode(msg));
    +        return meta;
         }
     
         function buildLine(msg, isConsec) {
    @@ -385,28 +456,32 @@
             line.dataset.meshId = msg.meshId || msg.firstId;
     
             var isDm = msg.toId && msg.toId !== BROADCAST;
    -        var display = esc(nodeShort(msg.fromId) || nodeName(msg.fromId));
    -        var full = esc(nodeName(msg.fromId));
    +        var display = nodeShort(msg.fromId) || nodeName(msg.fromId);
             var textClass = 'chat-text' + (isDm ? ' chat-dm' : '');
     
    -        var replyHtml = replySnippet(msg);
    -        var textHtml = linkify(msg.text);
    -
    -        line.innerHTML =
    -            '<span class="chat-ts timestamp-display" data-timestamp="' + msg.timestamp + '" data-timestamp-format="time">' + fmtTime(msg.timestamp) + '</span>' +
    -            '<span class="chat-nick ' + nickColor(msg.fromId) + '">' +
    -                '<a href="/node/' + msg.fromId + '" class="node-link" data-node-id="' + msg.fromId + '" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-html="true" data-bs-title="Loading..." title="' + full + '">' + display + '</a>' +
    -            '</span>' +
    -            '<span class="chat-sep">' + (isDm ? '&rarr;' : '|') + '</span>' +
    -            '<div class="chat-body">' +
    -                '<div class="chat-main">' +
    -                    '<span class="' + textClass + '">' + replyHtml + textHtml + '</span>' +
    -                    metaHtml(msg) +
    -                '</div>' +
    -                '<div class="chat-children"></div>' +
    -            '</div>';
    -
    -        msg.childrenEl = line.querySelector('.chat-children');
    +        var messageText = el('span', { className: textClass });
    +        var replyNode = replySnippet(msg);
    +        if (replyNode) messageText.appendChild(replyNode);
    +        messageText.appendChild(linkify(msg.text));
    +
    +        var childrenEl = el('div', { className: 'chat-children' });
    +        appendChildren(
    +            line,
    +            el('span', {
    +                className: 'chat-ts timestamp-display',
    +                dataset: { timestamp: msg.timestamp, timestampFormat: 'time' }
    +            }, fmtTime(msg.timestamp)),
    +            el('span', { className: 'chat-nick ' + nickColor(msg.fromId) },
    +                nodeAnchor(msg.fromId, display, null, '')
    +            ),
    +            el('span', { className: 'chat-sep' }, isDm ? '→' : '|'),
    +            el('div', { className: 'chat-body' },
    +                el('div', { className: 'chat-main' }, messageText, metaNode(msg)),
    +                childrenEl
    +            )
    +        );
    +
    +        msg.childrenEl = childrenEl;
     
             return line;
         }
    @@ -417,31 +492,23 @@
             line.id = 'msg-' + (msg.meshId || msg.firstId);
             line.dataset.meshId = msg.meshId || msg.firstId;
     
    -        var display = esc(nodeShort(msg.fromId) || nodeName(msg.fromId));
    -        var full = esc(nodeName(msg.fromId));
    -        var authorHtml =
    -            '<span class="chat-child-author ' + nickColor(msg.fromId) + '">' +
    -                '<a href="/node/' + msg.fromId + '" class="node-link" data-node-id="' + msg.fromId + '" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-html="true" data-bs-title="Loading..." title="' + full + '">' + display + '</a>' +
    -            '</span>';
    -        var labelHtml = msg.isEmoji
    -            ? '<span class="chat-child-label"><i class="bi bi-emoji-smile"></i> reacted</span>'
    -            : '<span class="chat-child-label"><i class="bi bi-reply"></i> replied</span>';
    +        var display = nodeShort(msg.fromId) || nodeName(msg.fromId);
             var textClass = 'chat-child-text' + (msg.toId && msg.toId !== BROADCAST ? ' chat-dm' : '') + (msg.isEmoji ? ' chat-emoji-reaction' : '');
    -        var textHtml = msg.isEmoji ? esc(msg.text) : linkify(msg.text);
    -
    -        line.innerHTML =
    -            labelHtml +
    -            authorHtml +
    -            '<span class="' + textClass + '">' + textHtml + '</span>' +
    -            metaHtml(msg);
    +        appendChildren(
    +            line,
    +            el('span', { className: 'chat-child-label' }, icon(msg.isEmoji ? 'bi bi-emoji-smile' : 'bi bi-reply'), ' ', msg.isEmoji ? 'reacted' : 'replied'),
    +            el('span', { className: 'chat-child-author ' + nickColor(msg.fromId) }, nodeAnchor(msg.fromId, display, null, '')),
    +            el('span', { className: textClass }, msg.isEmoji ? msg.text : linkify(msg.text)),
    +            metaNode(msg)
    +        );
     
             return line;
         }
     
         function insertDateSep(ts) {
             var sep = document.createElement('div');
             sep.className = 'chat-date-sep';
    -        sep.innerHTML = '<span>' + fmtDate(ts) + '</span>';
    +        sep.appendChild(el('span', fmtDate(ts)));
             messagesEl.appendChild(sep);
         }
     
    @@ -646,7 +713,12 @@
                 var tip = bootstrap.Tooltip.getInstance(el);
                 if (tip) tip.dispose();
             });
    -        messagesEl.innerHTML = '<div id="chatLoading" class="chat-loading"><div class="spinner-border spinner-border-sm text-secondary" role="status"></div><span class="text-muted ms-2">Loading messages…</span></div>';
    +        messagesEl.replaceChildren(
    +            el('div', { id: 'chatLoading', className: 'chat-loading' },
    +                el('div', { className: 'spinner-border spinner-border-sm text-secondary', role: 'status' }),
    +                el('span', { className: 'text-muted ms-2' }, 'Loading messages…')
    +            )
    +        );
             setStatus('loading');
             pushUrlState();
             loadInitial().then(startPolling);
    
  • src/malla/static/js/direct_receptions.js+52 37 modified
    @@ -120,15 +120,15 @@ class DirectReceptionsChart {
          * Show no data message
          */
         showNoDataMessage(direction, chartContainer) {
    -        chartContainer.innerHTML = `
    -            <div class="d-flex align-items-center justify-content-center h-100">
    -                <div class="text-center text-muted">
    -                    <i class="bi bi-info-circle" style="font-size: 2rem;"></i>
    -                    <div class="mt-2">No direct ${direction} data available</div>
    -                    <div class="small">Try switching to the other direction</div>
    -                </div>
    -            </div>
    -        `;
    +        chartContainer.replaceChildren(
    +            el('div', { className: 'd-flex align-items-center justify-content-center h-100' },
    +                el('div', { className: 'text-center text-muted' },
    +                    el('i', { className: 'bi bi-info-circle', style: { fontSize: '2rem' } }),
    +                    el('div', { className: 'mt-2' }, `No direct ${direction} data available`),
    +                    el('div', { className: 'small' }, 'Try switching to the other direction')
    +                )
    +            )
    +        );
         }
     
         /**
    @@ -139,15 +139,15 @@ class DirectReceptionsChart {
             document.getElementById('direct-receptions-loading').style.display = 'none';
             document.getElementById('direct-receptions-content').style.display = 'block';
     
    -        chartContainer.innerHTML = `
    -            <div class="d-flex align-items-center justify-content-center h-100">
    -                <div class="text-center text-danger">
    -                    <i class="bi bi-exclamation-triangle" style="font-size: 2rem;"></i>
    -                    <div class="mt-2">Error loading direct receptions data</div>
    -                    <div class="small">${error.message}</div>
    -                </div>
    -            </div>
    -        `;
    +        chartContainer.replaceChildren(
    +            el('div', { className: 'd-flex align-items-center justify-content-center h-100' },
    +                el('div', { className: 'text-center text-danger' },
    +                    el('i', { className: 'bi bi-exclamation-triangle', style: { fontSize: '2rem' } }),
    +                    el('div', { className: 'mt-2' }, 'Error loading direct receptions data'),
    +                    el('div', { className: 'small' }, error.message)
    +                )
    +            )
    +        );
     
             this.clearLegendTable('Error loading data');
         }
    @@ -304,7 +304,7 @@ class DirectReceptionsChart {
             const tbody = document.querySelector('#direct-receptions-legend tbody');
             if (!tbody) return;
     
    -        tbody.innerHTML = '';
    +        tbody.replaceChildren();
     
             this.nodeStats.forEach((stats, index) => {
                 const row = document.createElement('tr');
    @@ -314,22 +314,34 @@ class DirectReceptionsChart {
                 // Format values with proper null handling
                 const formatValue = (val, decimals = 1) => val !== null ? val.toFixed(decimals) : 'N/A';
     
    -            row.innerHTML = `
    -                <td>
    -                    <span class="d-inline-block" style="width: 12px; height: 12px; background-color: ${stats.color}; margin-right: 8px;"></span>
    -                    ${stats.label}
    -                </td>
    -                <td>${stats.packetCount}</td>
    -                <td>${formatValue(stats.rssiAvg)}</td>
    -                <td>${formatValue(stats.rssiMin)}</td>
    -                <td>${formatValue(stats.rssiMax)}</td>
    -                <td>${formatValue(stats.snrAvg)}</td>
    -                <td>${formatValue(stats.snrMin)}</td>
    -                <td>${formatValue(stats.snrMax)}</td>
    -                <td>
    -                    <input type="checkbox" ${stats.visible ? 'checked' : ''} class="form-check-input">
    -                </td>
    -            `;
    +            const checkbox = el('input', {
    +                type: 'checkbox',
    +                checked: stats.visible,
    +                className: 'form-check-input'
    +            });
    +
    +            row.append(
    +                el('td', null,
    +                    el('span', {
    +                        className: 'd-inline-block',
    +                        style: {
    +                            width: '12px',
    +                            height: '12px',
    +                            backgroundColor: stats.color,
    +                            marginRight: '8px'
    +                        }
    +                    }),
    +                    textNode(stats.label)
    +                ),
    +                el('td', null, String(stats.packetCount)),
    +                el('td', null, formatValue(stats.rssiAvg)),
    +                el('td', null, formatValue(stats.rssiMin)),
    +                el('td', null, formatValue(stats.rssiMax)),
    +                el('td', null, formatValue(stats.snrAvg)),
    +                el('td', null, formatValue(stats.snrMin)),
    +                el('td', null, formatValue(stats.snrMax)),
    +                el('td', null, checkbox)
    +            );
     
                 // Add click handler for row
                 row.addEventListener('click', (e) => {
    @@ -341,7 +353,6 @@ class DirectReceptionsChart {
                 });
     
                 // Add change handler for checkbox
    -            const checkbox = row.querySelector('input[type="checkbox"]');
                 checkbox.addEventListener('change', () => {
                     const datasetIndex = parseInt(row.dataset.datasetIndex);
                     this.nodeStats[datasetIndex].visible = checkbox.checked;
    @@ -420,7 +431,11 @@ class DirectReceptionsChart {
         clearLegendTable(message = 'No data available') {
             const tbody = document.querySelector('#direct-receptions-legend tbody');
             if (tbody) {
    -            tbody.innerHTML = `<tr><td colspan="9" class="text-center text-muted">${message}</td></tr>`;
    +            tbody.replaceChildren(
    +                el('tr', null,
    +                    el('td', { colspan: '9', className: 'text-center text-muted' }, message)
    +                )
    +            );
             }
         }
     
    
  • src/malla/static/js/dom.js+224 0 added
    @@ -0,0 +1,224 @@
    +(function() {
    +    function text(value) {
    +        return document.createTextNode(value == null ? '' : String(value));
    +    }
    +
    +    function appendChild(parent, child) {
    +        if (child == null || child === false) {
    +            return;
    +        }
    +
    +        if (Array.isArray(child)) {
    +            child.forEach((item) => appendChild(parent, item));
    +            return;
    +        }
    +
    +        if (child instanceof Node) {
    +            parent.appendChild(child);
    +            return;
    +        }
    +
    +        parent.appendChild(text(child));
    +    }
    +
    +    function appendChildren(parent, ...children) {
    +        children.forEach((child) => appendChild(parent, child));
    +        return parent;
    +    }
    +
    +    function setChildren(parent, ...children) {
    +        parent.replaceChildren();
    +        return appendChildren(parent, ...children);
    +    }
    +
    +    function el(tagName, attrs, ...children) {
    +        const element = document.createElement(tagName);
    +        const options = attrs && !Array.isArray(attrs) && !(attrs instanceof Node) ? attrs : null;
    +        const childItems = options ? children : [attrs, ...children];
    +
    +        if (options) {
    +            Object.entries(options).forEach(([key, value]) => {
    +                if (value == null || value === false) {
    +                    return;
    +                }
    +
    +                if (key === 'className') {
    +                    element.className = value;
    +                    return;
    +                }
    +
    +                if (key === 'text') {
    +                    element.textContent = String(value);
    +                    return;
    +                }
    +
    +                if (key === 'dataset' && value && typeof value === 'object') {
    +                    Object.entries(value).forEach(([dataKey, dataValue]) => {
    +                        if (dataValue != null) {
    +                            element.dataset[dataKey] = String(dataValue);
    +                        }
    +                    });
    +                    return;
    +                }
    +
    +                if (key === 'style' && value && typeof value === 'object') {
    +                    Object.entries(value).forEach(([styleKey, styleValue]) => {
    +                        element.style[styleKey] = styleValue;
    +                    });
    +                    return;
    +                }
    +
    +                if (key in element && key !== 'title') {
    +                    element[key] = value;
    +                    return;
    +                }
    +
    +                element.setAttribute(key, String(value));
    +            });
    +        }
    +
    +        appendChildren(element, ...childItems);
    +        return element;
    +    }
    +
    +    function fragment(...children) {
    +        const frag = document.createDocumentFragment();
    +        appendChildren(frag, ...children);
    +        return frag;
    +    }
    +
    +    function icon(className) {
    +        return el('i', { className });
    +    }
    +
    +    function badge(value, className) {
    +        return el('span', { className: `badge ${className || 'bg-secondary'}` }, value == null ? '' : String(value));
    +    }
    +
    +    function safePath(pathname, params) {
    +        const url = new URL(pathname, window.location.origin);
    +
    +        if (params && typeof params === 'object') {
    +            Object.entries(params).forEach(([key, value]) => {
    +                if (value == null) {
    +                    return;
    +                }
    +
    +                const stringValue = String(value).trim();
    +                if (stringValue) {
    +                    url.searchParams.set(key, stringValue);
    +                }
    +            });
    +        }
    +
    +        return `${url.pathname}${url.search}${url.hash}`;
    +    }
    +
    +    function safeUrl(value, options = {}) {
    +        if (value == null) {
    +            return null;
    +        }
    +
    +        const raw = String(value).trim();
    +        if (!raw) {
    +            return null;
    +        }
    +
    +        const allowRelative = options.allowRelative !== false;
    +        const allowedProtocols = options.allowedProtocols || ['http:', 'https:'];
    +
    +        try {
    +            const url = new URL(raw, window.location.origin);
    +            const isRelativeInput = !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw);
    +
    +            if (isRelativeInput && allowRelative) {
    +                return `${url.pathname}${url.search}${url.hash}`;
    +            }
    +
    +            if (url.origin === window.location.origin && allowRelative) {
    +                return `${url.pathname}${url.search}${url.hash}`;
    +            }
    +
    +            return allowedProtocols.includes(url.protocol) ? url.toString() : null;
    +        } catch (_) {
    +            return null;
    +        }
    +    }
    +
    +    function buttonLink(options) {
    +        const link = el('a', {
    +            href: options.href || '#',
    +            className: options.className || 'btn btn-sm btn-outline-secondary',
    +            title: options.title || '',
    +            target: options.target,
    +            rel: options.rel,
    +            ariaLabel: options.ariaLabel
    +        });
    +
    +        if (options.iconClass) {
    +            link.appendChild(icon(options.iconClass));
    +        }
    +
    +        if (options.text) {
    +            if (link.childNodes.length > 0) {
    +                link.appendChild(text(' '));
    +            }
    +            link.appendChild(text(options.text));
    +        }
    +
    +        return link;
    +    }
    +
    +    function nodeLink(nodeId, displayText, options = {}) {
    +        if (nodeId == null || nodeId === '') {
    +            return el('span', { className: 'text-muted' }, displayText || 'Unknown');
    +        }
    +
    +        const link = el('a', {
    +            href: safePath(`/node/${encodeURIComponent(String(nodeId))}`),
    +            className: options.className || 'text-decoration-none node-link',
    +            title: options.title || 'View node details'
    +        }, displayText || 'Unknown');
    +
    +        if (options.tooltip !== false) {
    +            link.dataset.nodeId = String(nodeId);
    +            link.dataset.bsToggle = 'tooltip';
    +            link.dataset.bsPlacement = options.tooltipPlacement || 'top';
    +            link.dataset.bsHtml = 'true';
    +            link.dataset.bsTitle = 'Loading...';
    +        }
    +
    +        return link;
    +    }
    +
    +    function joinNodes(items, separator) {
    +        const frag = document.createDocumentFragment();
    +        items.forEach((item, index) => {
    +            if (index > 0) {
    +                appendChild(frag, separator == null ? ' ' : separator);
    +            }
    +            appendChild(frag, item);
    +        });
    +        return frag;
    +    }
    +
    +    function escapeHtml(value) {
    +        const div = document.createElement('div');
    +        div.textContent = value == null ? '' : String(value);
    +        return div.innerHTML;
    +    }
    +
    +    window.el = el;
    +    window.fragment = fragment;
    +    window.icon = icon;
    +    window.badge = badge;
    +    window.textNode = text;
    +    window.appendChildren = appendChildren;
    +    window.setChildren = setChildren;
    +    window.safePath = safePath;
    +    window.safeUrl = safeUrl;
    +    window.buttonLink = buttonLink;
    +    window.nodeLink = nodeLink;
    +    window.joinNodes = joinNodes;
    +    window.escapeHtml = escapeHtml;
    +})();
    
  • src/malla/static/js/modern-table.js+213 233 modified
    @@ -1,8 +1,3 @@
    -/**
    - * Modern Table Implementation using HTMX and TanStack Table concepts
    - * Replaces DataTables with a more modern, lightweight solution
    - */
    -
     class ModernTable {
         constructor(containerId, options = {}) {
             this.container = document.getElementById(containerId);
    @@ -29,142 +24,148 @@ class ModernTable {
                 loading: false,
                 data: [],
                 totalCount: 0,
    -            totalPages: 0
    +            totalPages: 0,
    +            isGrouped: false
             };
     
             this.searchTimeout = null;
    -        this.eventListeners = {}; // Add event listener support
    +        this.eventListeners = {};
    +        this.paginationClickHandler = null;
             this.init();
         }
     
         init() {
             this.setupContainer();
             this.setupEventListeners();
    +
    +        window.modernTableInstances = window.modernTableInstances || [];
    +        window.modernTableInstances.push(this);
    +
             if (!this.options.deferInitialLoad) {
                 this.loadData();
             }
         }
     
         setupContainer() {
    -        this.container.innerHTML = `
    -            <div class="modern-table-container">
    -                ${this.options.enableSearch ? this.renderSearchBar() : ''}
    -                <div class="table-wrapper">
    -                    <table class="modern-table">
    -                        <thead>
    -                            ${this.renderTableHeader()}
    -                        </thead>
    -                        <tbody id="${this.container.id}-tbody">
    -                            ${this.renderLoadingState()}
    -                        </tbody>
    -                    </table>
    -                </div>
    -                ${this.options.enablePagination ? this.renderPagination() : ''}
    -            </div>
    -        `;
    -    }
    -
    -    renderSearchBar() {
    -        return `
    -            <div class="table-search-bar" style="padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;">
    -                <div class="row align-items-center">
    -                    <div class="col-md-6">
    -                        <div class="input-group">
    -                            <span class="input-group-text">
    -                                <i class="bi bi-search"></i>
    -                            </span>
    -                            <input type="text"
    -                                   class="form-control"
    -                                   placeholder="Search..."
    -                                   id="${this.container.id}-search">
    -                        </div>
    -                    </div>
    -                    <div class="col-md-6 text-end">
    -                        <div class="page-size-selector">
    -                            <label for="${this.container.id}-pagesize">Show:</label>
    -                            <select id="${this.container.id}-pagesize" class="form-select form-select-sm" style="width: auto; display: inline-block;">
    -                                <option value="10" ${this.state.pageSize === 10 ? 'selected' : ''}>10</option>
    -                                <option value="25" ${this.state.pageSize === 25 ? 'selected' : ''}>25</option>
    -                                <option value="50" ${this.state.pageSize === 50 ? 'selected' : ''}>50</option>
    -                                <option value="100" ${this.state.pageSize === 100 ? 'selected' : ''}>100</option>
    -                            </select>
    -                        </div>
    -                    </div>
    -                </div>
    -            </div>
    -        `;
    -    }
    -
    -    renderTableHeader() {
    -        return `
    -            <tr>
    -                ${this.options.columns.map(column => {
    -                    const sortable = column.sortable !== false;
    -                    const sortKey = column.sortKey || column.key;
    -                    const isSorted = this.state.sortBy === sortKey;
    -
    -                    // Use proper CSS classes for ::after pseudo-element
    -                    let sortClass = sortable ? 'sortable' : '';
    -                    if (isSorted) {
    -                        sortClass += ` ${this.state.sortOrder}`;
    -                    }
    -
    -                    return `
    -                        <th class="${sortClass}"
    -                            ${sortable ? `data-sort="${sortKey}"` : ''}>
    -                            ${column.title}
    -                        </th>
    -                    `;
    -                }).join('')}
    -            </tr>
    -        `;
    +        this.container.replaceChildren(
    +            el('div', { className: 'modern-table-container' },
    +                this.options.enableSearch ? this.buildSearchBar() : null,
    +                el('div', { className: 'table-wrapper' },
    +                    el('table', { className: 'modern-table' },
    +                        el('thead'),
    +                        el('tbody', { id: `${this.container.id}-tbody` })
    +                    )
    +                ),
    +                this.options.enablePagination ? this.buildPaginationShell() : null
    +            )
    +        );
    +
    +        this.updateTableHeader();
    +        this.showLoading();
    +    }
    +
    +    buildSearchBar() {
    +        const select = el('select', {
    +            id: `${this.container.id}-pagesize`,
    +            className: 'form-select form-select-sm',
    +            style: { width: 'auto', display: 'inline-block' }
    +        });
    +
    +        [10, 25, 50, 100].forEach((size) => {
    +            select.appendChild(el('option', {
    +                value: String(size),
    +                selected: this.state.pageSize === size
    +            }, String(size)));
    +        });
    +
    +        return el('div', {
    +            className: 'table-search-bar',
    +            style: { padding: '1rem 1.5rem', borderBottom: '1px solid #e5e7eb' }
    +        },
    +            el('div', { className: 'row align-items-center' },
    +                el('div', { className: 'col-md-6' },
    +                    el('div', { className: 'input-group' },
    +                        el('span', { className: 'input-group-text' }, icon('bi bi-search')),
    +                        el('input', {
    +                            type: 'text',
    +                            className: 'form-control',
    +                            placeholder: this.options.searchPlaceholder || 'Search...',
    +                            id: `${this.container.id}-search`
    +                        })
    +                    )
    +                ),
    +                el('div', { className: 'col-md-6 text-end' },
    +                    el('div', { className: 'page-size-selector' },
    +                        el('label', { htmlFor: `${this.container.id}-pagesize` }, 'Show:'),
    +                        textNode(' '),
    +                        select
    +                    )
    +                )
    +            )
    +        );
    +    }
    +
    +    buildPaginationShell() {
    +        return el('div', { className: 'modern-pagination' },
    +            el('div', { className: 'pagination-info' },
    +                'Showing ',
    +                el('span', { id: `${this.container.id}-start` }, '0'),
    +                ' to ',
    +                el('span', { id: `${this.container.id}-end` }, '0'),
    +                ' of ',
    +                el('span', { id: `${this.container.id}-total` }, '0'),
    +                ' entries'
    +            ),
    +            el('div', { className: 'pagination-controls', id: `${this.container.id}-pagination` })
    +        );
    +    }
    +
    +    buildHeaderRow() {
    +        return el('tr', null,
    +            this.options.columns.map((column) => {
    +                const sortable = column.sortable !== false;
    +                const sortKey = column.sortKey || column.key;
    +                const isSorted = this.state.sortBy === sortKey;
    +                let sortClass = sortable ? 'sortable' : '';
    +                if (isSorted) {
    +                    sortClass += ` ${this.state.sortOrder}`;
    +                }
    +
    +                return el('th', {
    +                    className: sortClass,
    +                    ...(sortable ? { 'data-sort': sortKey } : {})
    +                }, column.title);
    +            })
    +        );
         }
     
         renderLoadingState() {
    -        return `
    -            <tr>
    -                <td colspan="${this.options.columns.length}" class="text-center py-4">
    -                    <div class="loading-spinner mx-auto"></div>
    -                    <div class="mt-2 text-muted">Loading...</div>
    -                </td>
    -            </tr>
    -        `;
    +        return el('tr', null,
    +            el('td', {
    +                colspan: String(this.options.columns.length),
    +                className: 'text-center py-4'
    +            },
    +                el('div', { className: 'loading-spinner mx-auto' }),
    +                el('div', { className: 'mt-2 text-muted' }, 'Loading...')
    +            )
    +        );
         }
     
         renderEmptyState() {
    -        return `
    -            <tr>
    -                <td colspan="${this.options.columns.length}">
    -                    <div class="empty-state">
    -                        <div class="empty-state-icon">
    -                            <i class="bi bi-inbox"></i>
    -                        </div>
    -                        <div class="empty-state-title">No data found</div>
    -                        <div class="empty-state-description">
    -                            ${this.state.search ? 'Try adjusting your search terms' : 'No records match your current filters'}
    -                        </div>
    -                    </div>
    -                </td>
    -            </tr>
    -        `;
    -    }
    -
    -    renderPagination() {
    -        return `
    -            <div class="modern-pagination">
    -                <div class="pagination-info">
    -                    Showing <span id="${this.container.id}-start">0</span> to <span id="${this.container.id}-end">0</span>
    -                    of <span id="${this.container.id}-total">0</span> entries
    -                </div>
    -                <div class="pagination-controls" id="${this.container.id}-pagination">
    -                    <!-- Pagination buttons will be rendered here -->
    -                </div>
    -            </div>
    -        `;
    +        return el('tr', null,
    +            el('td', { colspan: String(this.options.columns.length) },
    +                el('div', { className: 'empty-state' },
    +                    el('div', { className: 'empty-state-icon' }, icon('bi bi-inbox')),
    +                    el('div', { className: 'empty-state-title' }, this.options.emptyMessage || 'No data found'),
    +                    el('div', { className: 'empty-state-description' },
    +                        this.state.search ? 'Try adjusting your search terms' : 'No records match your current filters'
    +                    )
    +                )
    +            )
    +        );
         }
     
         setupEventListeners() {
    -        // Search functionality
             if (this.options.enableSearch) {
                 const searchInput = document.getElementById(`${this.container.id}-search`);
                 if (searchInput) {
    @@ -178,39 +179,37 @@ class ModernTable {
                     });
                 }
     
    -            // Page size selector
                 const pageSizeSelect = document.getElementById(`${this.container.id}-pagesize`);
                 if (pageSizeSelect) {
                     pageSizeSelect.addEventListener('change', (e) => {
    -                    this.state.pageSize = parseInt(e.target.value);
    +                    this.state.pageSize = parseInt(e.target.value, 10);
                         this.state.page = 1;
                         this.loadData();
                     });
                 }
             }
     
    -        // Add sorting event listeners
             if (this.options.enableSorting) {
                 this.container.addEventListener('click', (e) => {
                     const th = e.target.closest('th[data-sort]');
    -                if (th) {
    -                    const sortBy = th.dataset.sort;
    -                    if (this.state.sortBy === sortBy) {
    -                        this.state.sortOrder = this.state.sortOrder === 'asc' ? 'desc' : 'asc';
    -                    } else {
    -                        this.state.sortBy = sortBy;
    -                        this.state.sortOrder = 'desc';
    -                    }
    -                    this.updateTableHeader();
    -                    this.loadData();
    +                if (!th) return;
    +
    +                const sortBy = th.dataset.sort;
    +                if (this.state.sortBy === sortBy) {
    +                    this.state.sortOrder = this.state.sortOrder === 'asc' ? 'desc' : 'asc';
    +                } else {
    +                    this.state.sortBy = sortBy;
    +                    this.state.sortOrder = 'desc';
                     }
    +                this.updateTableHeader();
    +                this.loadData();
                 });
             }
         }
     
         updateTableHeader() {
             const thead = this.container.querySelector('thead');
    -        thead.innerHTML = this.renderTableHeader();
    +        setChildren(thead, this.buildHeaderRow());
         }
     
         async loadData() {
    @@ -229,15 +228,12 @@ class ModernTable {
                     ...this.state.filters
                 });
     
    -            // Add grouping parameter - prefer filter value over DOM check to avoid race conditions
                 if ('group_packets' in this.state.filters) {
    -                // Use the filter value when available (from reactive updates)
                     params.set('group_packets', this.state.filters.group_packets.toString());
                 } else {
    -                // Fall back to DOM check for backward compatibility
                     const groupingCheckbox = document.getElementById('groupPackets') ||
    -                                       document.getElementById('groupTraceroutes') ||
    -                                       document.getElementById('group_packets');
    +                    document.getElementById('groupTraceroutes') ||
    +                    document.getElementById('group_packets');
                     if (groupingCheckbox && groupingCheckbox.checked) {
                         params.set('group_packets', 'true');
                     }
    @@ -252,15 +248,11 @@ class ModernTable {
                 this.state.data = data.data || [];
                 this.state.totalCount = data.total_count || 0;
                 this.state.totalPages = Math.ceil(this.state.totalCount / this.state.pageSize);
    -
    -            // Track if this is a grouped query for pagination display
                 this.state.isGrouped = params.get('group_packets') === 'true';
     
                 this.renderTableBody();
                 this.updatePagination();
    -
                 this.emit('dataLoaded', { data: this.state.data, totalCount: this.state.totalCount });
    -
             } catch (error) {
                 console.error('Error loading table data:', error);
                 this.showError(error.message);
    @@ -271,38 +263,38 @@ class ModernTable {
     
         showLoading() {
             const tbody = document.getElementById(`${this.container.id}-tbody`);
    -        tbody.innerHTML = this.renderLoadingState();
    +        setChildren(tbody, this.renderLoadingState());
         }
     
         showError(message) {
             const tbody = document.getElementById(`${this.container.id}-tbody`);
    -        tbody.innerHTML = `
    -            <tr>
    -                <td colspan="${this.options.columns.length}" class="text-center py-4 text-danger">
    -                    <i class="bi bi-exclamation-triangle fs-1 mb-2"></i>
    -                    <div>Error loading data: ${message}</div>
    -                </td>
    -            </tr>
    -        `;
    +        setChildren(tbody,
    +            el('tr', null,
    +                el('td', {
    +                    colspan: String(this.options.columns.length),
    +                    className: 'text-center py-4 text-danger'
    +                },
    +                    icon('bi bi-exclamation-triangle fs-1 mb-2'),
    +                    el('div', null, `Error loading data: ${message}`)
    +                )
    +            )
    +        );
         }
     
         renderTableBody() {
             const tbody = document.getElementById(`${this.container.id}-tbody`);
     
             if (this.state.data.length === 0) {
    -            tbody.innerHTML = this.renderEmptyState();
    +            setChildren(tbody, this.renderEmptyState());
                 return;
             }
     
    -        tbody.innerHTML = this.state.data.map(row => `
    -            <tr>
    -                ${this.options.columns.map(column => `
    -                    <td>${this.renderCell(row, column)}</td>
    -                `).join('')}
    -            </tr>
    -        `).join('');
    +        const rows = this.state.data.map((row) => el('tr', null,
    +            this.options.columns.map((column) => el('td', null, this.renderCell(row, column)))
    +        ));
    +
    +        setChildren(tbody, rows);
     
    -        // Re-initialize tooltips and other interactive elements
             if (window.reinitializeTooltips) {
                 window.reinitializeTooltips();
             }
    @@ -312,7 +304,11 @@ class ModernTable {
             const value = this.getNestedValue(row, column.key);
     
             if (column.render && typeof column.render === 'function') {
    -            return column.render(value, row);
    +            const rendered = column.render(value, row);
    +            if (typeof rendered === 'string') {
    +                throw new Error(`ModernTable renderers must return DOM nodes, not strings (column: ${column.key})`);
    +            }
    +            return rendered == null ? textNode('') : rendered;
             }
     
             if (column.type === 'badge') {
    @@ -328,24 +324,30 @@ class ModernTable {
             }
     
             if (column.type === 'link') {
    -            return `<a href="${column.linkTemplate.replace('{id}', row.id)}" class="text-decoration-none">${value || 'Unknown'}</a>`;
    +            return this.renderLink(value, row, column);
             }
     
    -        return value || '';
    +        return textNode(value == null ? '' : String(value));
         }
     
         getNestedValue(obj, path) {
             return path.split('.').reduce((current, key) => current?.[key], obj);
         }
     
    +    renderLink(value, row, column) {
    +        const href = safePath(column.linkTemplate.replace('{id}', encodeURIComponent(String(row.id))));
    +        return el('a', { href, className: 'text-decoration-none' }, value || 'Unknown');
    +    }
    +
         renderBadge(value, badgeMap = {}) {
    -        const badgeClass = badgeMap[value] || 'modern-badge-secondary';
    -        return `<span class="modern-badge ${badgeClass}">${value}</span>`;
    +        return el('span', {
    +            className: `modern-badge ${badgeMap[value] || 'modern-badge-secondary'}`
    +        }, value == null ? '' : String(value));
         }
     
         renderSignalIndicator(value, unit = '') {
             if (value === null || value === undefined || value === '') {
    -            return '<span class="text-muted">N/A</span>';
    +            return el('span', { className: 'text-muted' }, 'N/A');
             }
     
             const numValue = parseFloat(value);
    @@ -361,137 +363,119 @@ class ModernTable {
                 else if (numValue > -5) className = 'signal-fair';
             }
     
    -        return `<span class="${className}">${value}${unit ? ' ' + unit : ''}</span>`;
    +        return el('span', { className }, `${value}${unit ? ` ${unit}` : ''}`);
         }
     
         renderActions(row, actions = []) {
    -        return `
    -            <div class="action-buttons">
    -                ${actions.map(action => `
    -                    <a href="${action.url.replace('{id}', row.id)}"
    -                       class="action-btn ${action.class || ''}"
    -                       title="${action.title || ''}">
    -                        <i class="bi bi-${action.icon}"></i>
    -                    </a>
    -                `).join('')}
    -            </div>
    -        `;
    +        return el('div', { className: 'action-buttons' },
    +            actions.map((action) => el('a', {
    +                href: safePath(action.url.replace('{id}', encodeURIComponent(String(row.id)))),
    +                className: `action-btn ${action.class || ''}`.trim(),
    +                title: action.title || ''
    +            }, icon(`bi bi-${action.icon}`)))
    +        );
         }
     
         updatePagination() {
             if (!this.options.enablePagination) return;
     
    -        // Update info
             const start = (this.state.page - 1) * this.state.pageSize + 1;
             const end = Math.min(this.state.page * this.state.pageSize, this.state.totalCount);
     
             document.getElementById(`${this.container.id}-start`).textContent = this.state.totalCount > 0 ? start : 0;
             document.getElementById(`${this.container.id}-end`).textContent = end;
     
    -        // Handle estimated counts for grouped queries
             const totalElement = document.getElementById(`${this.container.id}-total`);
             if (this.state.isGrouped && this.state.data.length === this.state.pageSize) {
    -            // For grouped queries where we got a full page, show estimated count
                 totalElement.textContent = `${this.state.totalCount}+`;
                 totalElement.title = 'Estimated count (optimized for performance)';
             } else {
                 totalElement.textContent = this.state.totalCount;
                 totalElement.title = '';
             }
     
    -        // Update pagination controls
             const paginationContainer = document.getElementById(`${this.container.id}-pagination`);
    -        paginationContainer.innerHTML = this.renderPaginationButtons();
    +        setChildren(paginationContainer, this.renderPaginationButtons());
    +
    +        if (this.paginationClickHandler) {
    +            paginationContainer.removeEventListener('click', this.paginationClickHandler);
    +        }
     
    -        // Add event listeners to pagination buttons
    -        paginationContainer.addEventListener('click', (e) => {
    +        this.paginationClickHandler = (e) => {
                 const button = e.target.closest('.pagination-btn');
                 if (button && !button.disabled) {
    -                const page = parseInt(button.dataset.page);
    +                const page = parseInt(button.dataset.page, 10);
                     if (page && page !== this.state.page) {
                         this.state.page = page;
                         this.loadData();
                     }
                 }
    -        });
    +        };
    +
    +        paginationContainer.addEventListener('click', this.paginationClickHandler);
         }
     
         renderPaginationButtons() {
             const { page, totalPages } = this.state;
    -        const buttons = [];
    -
    -        // Previous button
    -        buttons.push(`
    -            <button class="pagination-btn"
    -                    data-page="${page - 1}"
    -                    ${page <= 1 ? 'disabled' : ''}>
    -                <i class="bi bi-chevron-left"></i> Previous
    -            </button>
    -        `);
    -
    -        // For grouped queries with estimated counts, limit pagination display
    +        const nodes = [];
    +
    +        nodes.push(el('button', {
    +            className: 'pagination-btn',
    +            dataset: { page: page - 1 },
    +            disabled: page <= 1
    +        }, icon('bi bi-chevron-left'), textNode(' Previous')));
    +
             if (this.state.isGrouped && this.state.data.length === this.state.pageSize) {
    -            // Show current page and next few pages only
                 const maxDisplayPages = Math.min(totalPages, page + 5);
    -
                 for (let i = Math.max(1, page - 2); i <= Math.min(maxDisplayPages, page + 2); i++) {
    -                buttons.push(`
    -                    <button class="pagination-btn ${i === page ? 'active' : ''}"
    -                            data-page="${i}">
    -                        ${i}
    -                    </button>
    -                `);
    +                nodes.push(this.createPageButton(i, i === page));
                 }
    -
                 if (page < maxDisplayPages) {
    -                buttons.push(`<span class="pagination-ellipsis">...</span>`);
    +                nodes.push(el('span', { className: 'pagination-ellipsis' }, '...'));
                 }
             } else {
    -            // Standard pagination for exact counts
                 const startPage = Math.max(1, page - 2);
                 const endPage = Math.min(totalPages, page + 2);
     
                 if (startPage > 1) {
    -                buttons.push(`<button class="pagination-btn" data-page="1">1</button>`);
    +                nodes.push(this.createPageButton(1, false));
                     if (startPage > 2) {
    -                    buttons.push(`<span class="pagination-ellipsis">...</span>`);
    +                    nodes.push(el('span', { className: 'pagination-ellipsis' }, '...'));
                     }
                 }
     
                 for (let i = startPage; i <= endPage; i++) {
    -                buttons.push(`
    -                    <button class="pagination-btn ${i === page ? 'active' : ''}"
    -                            data-page="${i}">
    -                        ${i}
    -                    </button>
    -                `);
    +                nodes.push(this.createPageButton(i, i === page));
                 }
     
                 if (endPage < totalPages) {
                     if (endPage < totalPages - 1) {
    -                    buttons.push(`<span class="pagination-ellipsis">...</span>`);
    +                    nodes.push(el('span', { className: 'pagination-ellipsis' }, '...'));
                     }
    -                buttons.push(`<button class="pagination-btn" data-page="${totalPages}">${totalPages}</button>`);
    +                nodes.push(this.createPageButton(totalPages, false));
                 }
             }
     
    -        // Next button - for grouped queries, only disable if we got less than a full page
             const hasNextPage = this.state.isGrouped ?
                 this.state.data.length === this.state.pageSize :
                 page < totalPages;
     
    -        buttons.push(`
    -            <button class="pagination-btn"
    -                    data-page="${page + 1}"
    -                    ${!hasNextPage ? 'disabled' : ''}>
    -                Next <i class="bi bi-chevron-right"></i>
    -            </button>
    -        `);
    +        nodes.push(el('button', {
    +            className: 'pagination-btn',
    +            dataset: { page: page + 1 },
    +            disabled: !hasNextPage
    +        }, textNode('Next '), icon('bi bi-chevron-right')));
    +
    +        return nodes;
    +    }
     
    -        return buttons.join('');
    +    createPageButton(pageNumber, active) {
    +        return el('button', {
    +            className: `pagination-btn${active ? ' active' : ''}`,
    +            dataset: { page: pageNumber }
    +        }, String(pageNumber));
         }
     
    -    // Public methods for external control
         setFilters(filters) {
             this.state.filters = filters;
             this.state.page = 1;
    @@ -511,7 +495,6 @@ class ModernTable {
         updateColumns(newColumns) {
             this.options.columns = newColumns;
             this.updateTableHeader();
    -        // Re-render the table body with the new columns
             this.renderTableBody();
         }
     
    @@ -538,21 +521,18 @@ class ModernTable {
             this.loadData();
         }
     
    -    // Add event listener support
         on(event, callback) {
             if (!this.eventListeners[event]) {
                 this.eventListeners[event] = [];
             }
             this.eventListeners[event].push(callback);
         }
     
    -    // Emit events
         emit(event, data) {
             if (this.eventListeners[event]) {
    -            this.eventListeners[event].forEach(callback => callback(data));
    +            this.eventListeners[event].forEach((callback) => callback(data));
             }
         }
     }
     
    -// Export for use in other scripts
     window.ModernTable = ModernTable;
    
  • src/malla/static/js/node-picker.js+59 49 modified
    @@ -223,7 +223,7 @@ class NodePicker {
                 return;
             }
     
    -        const html = this.nodes.map(node => {
    +        const items = this.nodes.map(node => {
                 const longName = node.long_name || null;
                 const shortName = node.short_name || null;
                 let displayName = longName || shortName || 'Unnamed';
    @@ -245,25 +245,37 @@ class NodePicker {
                     details.push('Popular Node');
                 }
     
    -            // Escape HTML attributes for Firefox compatibility
    -            const escapedDisplayName = this.escapeHtml(displayName);
    -            const escapedShortName = this.escapeHtml(shortName || '');
    -            const escapedNodeId = this.escapeHtml(node.node_id.toString());
    +            const item = document.createElement('div');
    +            item.className = 'node-picker-item';
    +            item.dataset.nodeId = node.node_id.toString();
    +            item.dataset.displayName = displayName;
    +            item.dataset.shortName = shortName || '';
    +
    +            const nameDiv = document.createElement('div');
    +            nameDiv.className = 'node-picker-item-name';
    +            nameDiv.textContent = displayName;
    +            item.appendChild(nameDiv);
    +
    +            const idDiv = document.createElement('div');
    +            idDiv.className = 'node-picker-item-id';
    +            idDiv.textContent = hexId;
    +            item.appendChild(idDiv);
    +
    +            if (details.length > 0) {
    +                const detailsDiv = document.createElement('div');
    +                detailsDiv.className = 'node-picker-item-details';
    +                detailsDiv.textContent = details.join(' • ');
    +                item.appendChild(detailsDiv);
    +            }
     
    -            return `
    -                <div class="node-picker-item" data-node-id="${escapedNodeId}" data-display-name="${escapedDisplayName}" data-short-name="${escapedShortName}">
    -                    <div class="node-picker-item-name">${escapedDisplayName}</div>
    -                    <div class="node-picker-item-id">${hexId}</div>
    -                    ${details.length > 0 ? `<div class="node-picker-item-details">${details.join(' • ')}</div>` : ''}
    -                </div>
    -            `;
    -        }).join('');
    +            return item;
    +        });
     
    -        this.resultsContainer.innerHTML = html;
    +        this.resultsContainer.replaceChildren(...items);
     
             // Add click listeners to items - Firefox-compatible approach
    -        const items = this.resultsContainer.querySelectorAll('.node-picker-item');
    -        items.forEach(item => {
    +        const renderedItems = this.resultsContainer.querySelectorAll('.node-picker-item');
    +        renderedItems.forEach(item => {
                 item.addEventListener('click', (e) => {
                     e.preventDefault();
                     e.stopPropagation();
    @@ -278,13 +290,6 @@ class NodePicker {
     
             this.currentFocus = -1;
         }
    -
    -    escapeHtml(text) {
    -        const div = document.createElement('div');
    -        div.textContent = text;
    -        return div.innerHTML;
    -    }
    -
         selectNode(nodeId, displayName = null) {
             // Find the node data if displayName not provided
             if (!displayName) {
    @@ -345,7 +350,7 @@ class NodePicker {
         showLoading() {
             this.loadingElement.style.display = 'block';
             this.noResultsElement.style.display = 'none';
    -        this.resultsContainer.innerHTML = '';
    +        this.resultsContainer.replaceChildren();
         }
     
         hideLoading() {
    @@ -355,7 +360,7 @@ class NodePicker {
         showNoResults() {
             this.noResultsElement.style.display = 'block';
             this.loadingElement.style.display = 'none';
    -        this.resultsContainer.innerHTML = '';
    +        this.resultsContainer.replaceChildren();
         }
     
         hideNoResults() {
    @@ -620,7 +625,7 @@ class GatewayPicker {
                 return;
             }
     
    -        const html = this.gateways.map(gateway => {
    +        const items = this.gateways.map(gateway => {
                 // Handle both string gateways and gateway objects
                 let gatewayId, displayName, details = [];
     
    @@ -648,24 +653,36 @@ class GatewayPicker {
                     }
                 }
     
    -            // Escape HTML attributes for Firefox compatibility
    -            const escapedGatewayId = this.escapeHtml(gatewayId);
    -            const escapedDisplayName = this.escapeHtml(displayName);
    +            const item = document.createElement('div');
    +            item.className = 'gateway-picker-item';
    +            item.dataset.gatewayId = gatewayId;
    +            item.dataset.displayName = displayName;
    +
    +            const nameDiv = document.createElement('div');
    +            nameDiv.className = 'gateway-picker-item-name';
    +            nameDiv.textContent = displayName;
    +            item.appendChild(nameDiv);
    +
    +            const idDiv = document.createElement('div');
    +            idDiv.className = 'gateway-picker-item-id';
    +            idDiv.textContent = gatewayId;
    +            item.appendChild(idDiv);
    +
    +            if (details.length > 0) {
    +                const detailsDiv = document.createElement('div');
    +                detailsDiv.className = 'gateway-picker-item-details';
    +                detailsDiv.textContent = details.join(' • ');
    +                item.appendChild(detailsDiv);
    +            }
     
    -            return `
    -                <div class="gateway-picker-item" data-gateway-id="${escapedGatewayId}" data-display-name="${escapedDisplayName}">
    -                    <div class="gateway-picker-item-name">${escapedDisplayName}</div>
    -                    <div class="gateway-picker-item-id">${escapedGatewayId}</div>
    -                    ${details.length > 0 ? `<div class="gateway-picker-item-details">${details.join(' • ')}</div>` : ''}
    -                </div>
    -            `;
    -        }).join('');
    +            return item;
    +        });
     
    -        this.resultsContainer.innerHTML = html;
    +        this.resultsContainer.replaceChildren(...items);
     
             // Add click listeners to items - Firefox-compatible approach
    -        const items = this.resultsContainer.querySelectorAll('.gateway-picker-item');
    -        items.forEach(item => {
    +        const renderedItems = this.resultsContainer.querySelectorAll('.gateway-picker-item');
    +        renderedItems.forEach(item => {
                 item.addEventListener('click', (e) => {
                     e.preventDefault();
                     e.stopPropagation();
    @@ -680,13 +697,6 @@ class GatewayPicker {
     
             this.currentFocus = -1;
         }
    -
    -    escapeHtml(text) {
    -        const div = document.createElement('div');
    -        div.textContent = text;
    -        return div.innerHTML;
    -    }
    -
         selectGateway(gatewayId, displayName = null) {
             // Convert Meshtastic hex-style ID (e.g., !abcd1234) to decimal node ID for consistency
             let storedId = gatewayId;
    @@ -742,7 +752,7 @@ class GatewayPicker {
         showLoading() {
             this.loadingElement.style.display = 'block';
             this.noResultsElement.style.display = 'none';
    -        this.resultsContainer.innerHTML = '';
    +        this.resultsContainer.replaceChildren();
         }
     
         hideLoading() {
    @@ -752,7 +762,7 @@ class GatewayPicker {
         showNoResults() {
             this.noResultsElement.style.display = 'block';
             this.loadingElement.style.display = 'none';
    -        this.resultsContainer.innerHTML = '';
    +        this.resultsContainer.replaceChildren();
         }
     
         hideNoResults() {
    
  • src/malla/static/js/relay_node_analysis.js+38 32 modified
    @@ -82,50 +82,53 @@ class RelayNodeAnalysis {
             const tbody = container.querySelector('tbody');
             if (!tbody) return;
     
    -        tbody.innerHTML = '';
    +        tbody.replaceChildren();
     
             stats.forEach(stat => {
                 const row = document.createElement('tr');
     
    -            // Relay node column
                 const relayCell = document.createElement('td');
    -            relayCell.innerHTML = `<code class="text-primary">${stat.relay_hex}</code>`;
    +            relayCell.appendChild(el('code', { className: 'text-primary' }, stat.relay_hex));
                 row.appendChild(relayCell);
     
    -            // Count column
                 const countCell = document.createElement('td');
    -            countCell.innerHTML = `<span class="badge bg-info">${stat.count}</span>`;
    +            countCell.appendChild(badge(String(stat.count), 'bg-info'));
                 row.appendChild(countCell);
     
    -            // Avg RSSI column
                 const rssiCell = document.createElement('td');
                 if (stat.avg_rssi !== null && stat.avg_rssi !== undefined) {
                     const rssiValue = stat.avg_rssi.toFixed(1);
    -                rssiCell.innerHTML = `<span class="text-muted">${rssiValue} dBm</span>`;
    +                rssiCell.appendChild(el('span', { className: 'text-muted' }, `${rssiValue} dBm`));
                 } else {
    -                rssiCell.innerHTML = '<span class="text-muted">-</span>';
    +                rssiCell.appendChild(el('span', { className: 'text-muted' }, '-'));
                 }
                 row.appendChild(rssiCell);
     
    -            // Avg SNR column
                 const snrCell = document.createElement('td');
                 if (stat.avg_snr !== null && stat.avg_snr !== undefined) {
                     const snrValue = stat.avg_snr.toFixed(1);
    -                snrCell.innerHTML = `<span class="text-muted">${snrValue} dB</span>`;
    +                snrCell.appendChild(el('span', { className: 'text-muted' }, `${snrValue} dB`));
                 } else {
    -                snrCell.innerHTML = '<span class="text-muted">-</span>';
    +                snrCell.appendChild(el('span', { className: 'text-muted' }, '-'));
                 }
                 row.appendChild(snrCell);
     
    -            // Candidates column
                 const candidatesCell = document.createElement('td');
                 if (stat.candidates && stat.candidates.length > 0) {
    -                const candidateLinks = stat.candidates.map(candidate => {
    -                    return `<a href="/node/${candidate.node_id}" class="node-link text-decoration-none">${candidate.node_name}</a> <small class="text-muted">(${candidate.last_byte})</small>`;
    -                }).join(', ');
    -                candidatesCell.innerHTML = candidateLinks;
    +                const candidateNodes = [];
    +                stat.candidates.forEach((candidate, index) => {
    +                    if (index > 0) candidateNodes.push(textNode(', '));
    +                    candidateNodes.push(
    +                        nodeLink(candidate.node_id, candidate.node_name, {
    +                            className: 'node-link text-decoration-none'
    +                        }),
    +                        textNode(' '),
    +                        el('small', { className: 'text-muted' }, `(${candidate.last_byte})`)
    +                    );
    +                });
    +                candidatesCell.appendChild(fragment(candidateNodes));
                 } else {
    -                candidatesCell.innerHTML = '<span class="text-muted">No matching 0-hop nodes</span>';
    +                candidatesCell.appendChild(el('span', { className: 'text-muted' }, 'No matching 0-hop nodes'));
                 }
                 row.appendChild(candidatesCell);
     
    @@ -140,14 +143,16 @@ class RelayNodeAnalysis {
             const tbody = container.querySelector('tbody');
             if (!tbody) return;
     
    -        tbody.innerHTML = `
    -            <tr>
    -                <td colspan="5" class="text-center text-muted py-3">
    -                    <i class="bi bi-info-circle"></i> No relay node data available for this gateway.
    -                    <br><small>This node may not have reported any packets with relay_node information.</small>
    -                </td>
    -            </tr>
    -        `;
    +        tbody.replaceChildren(
    +            el('tr', null,
    +                el('td', { colspan: '5', className: 'text-center text-muted py-3' },
    +                    icon('bi bi-info-circle'),
    +                    textNode(' No relay node data available for this gateway.'),
    +                    el('br'),
    +                    el('small', null, 'This node may not have reported any packets with relay_node information.')
    +                )
    +            )
    +        );
         }
     
         /**
    @@ -161,13 +166,14 @@ class RelayNodeAnalysis {
             const tbody = tableContainer.querySelector('tbody');
             if (!tbody) return;
     
    -        tbody.innerHTML = `
    -            <tr>
    -                <td colspan="5" class="text-center text-danger py-3">
    -                    <i class="bi bi-exclamation-triangle"></i> Error loading relay node analysis: ${error.message}
    -                </td>
    -            </tr>
    -        `;
    +        tbody.replaceChildren(
    +            el('tr', null,
    +                el('td', { colspan: '5', className: 'text-center text-danger py-3' },
    +                    icon('bi bi-exclamation-triangle'),
    +                    textNode(` Error loading relay node analysis: ${error.message}`)
    +                )
    +            )
    +        );
         }
     }
     
    
  • src/malla/static/js/timezone-utils.js+12 11 modified
    @@ -161,23 +161,24 @@ function escapeHtml(str) {
      * @param {string} timestampField - Field name containing the timestamp (default: 'timestamp')
      * @param {string} idField - Field name for the row ID (default: 'id')
      * @param {string} linkPath - Path template for link (default: '/packet/{id}')
    - * @returns {string} HTML for timestamp cell
    + * @returns {Node} link for timestamp cell
      */
     function renderTimestampColumn(row, timestampField = 'timestamp', idField = 'id', linkPath = '/packet/{id}') {
         const timestamp = row[timestampField];
         const formattedTime = formatTimestamp(timestamp);
         const id = row[idField];
     
    -    // Escape HTML to prevent XSS
    -    const escapedId = escapeHtml(id);
    -    const escapedFormattedTime = escapeHtml(formattedTime);
    -    const escapedTimestamp = escapeHtml(timestamp);
    -
    -    const link = linkPath.replace('{id}', escapedId);
    -
    -    return `<a href="${link}" class="text-decoration-none" title="View details">
    -                <small class="timestamp-display" data-timestamp="${escapedTimestamp}">${escapedFormattedTime}</small>
    -            </a>`;
    +    const href = safePath(linkPath.replace('{id}', encodeURIComponent(String(id))));
    +    const small = el('small', {
    +        className: 'timestamp-display',
    +        dataset: { timestamp: timestamp }
    +    }, formattedTime);
    +
    +    return el('a', {
    +        href,
    +        className: 'text-decoration-none',
    +        title: 'View details'
    +    }, small);
     }
     
     /**
    
  • src/malla/static/js/url-filter-manager.js+1 9 modified
    @@ -272,15 +272,7 @@ class URLFilterManager {
          * Create a URL with filters for linking to filtered views
          */
         createFilteredURL(baseUrl, filters) {
    -        const url = new URL(baseUrl, window.location.origin);
    -
    -        Object.entries(filters).forEach(([key, value]) => {
    -            if (value && value.toString().trim()) {
    -                url.searchParams.set(key, value);
    -            }
    -        });
    -
    -        return url.toString();
    +        return safePath(baseUrl, filters);
         }
     }
     
    
  • src/malla/templates/base.html+4 2 modified
    @@ -207,6 +207,8 @@
         <!-- Timezone Toggle -->
         <script src="{{ url_for('static', filename='js/timezone-toggle.js') }}"></script>
     
    +    <script src="{{ url_for('static', filename='js/dom.js') }}"></script>
    +
         <!-- Node tooltip functionality -->
         <script>
             // Cache for node information to avoid repeated API calls
    @@ -335,7 +337,7 @@
     
                 if (nodeInfo.error) {
                     tooltip.setContent({
    -                    '.tooltip-inner': buildTooltipContent(nodeInfo, true).outerHTML
    +                    '.tooltip-inner': buildTooltipContent(nodeInfo, true)
                     });
                     return;
                 }
    @@ -345,7 +347,7 @@
                 const content = buildTooltipContent(node);
     
                 tooltip.setContent({
    -                '.tooltip-inner': content.outerHTML
    +                '.tooltip-inner': content
                 });
             }
     
    
  • src/malla/templates/components/shared_sidebar_assets.html+40 24 modified
    @@ -310,7 +310,11 @@
     
         if (nodes.length === 0) {
             const message = isSearchResults ? 'No nodes found' : 'No nodes available';
    -        container.innerHTML = `<div class="text-center text-muted py-4"><small>${message}</small></div>`;
    +        setChildren(container,
    +            el('div', { className: 'text-center text-muted py-4' },
    +                el('small', null, message)
    +            )
    +        );
             return;
         }
     
    @@ -321,44 +325,59 @@
             return nameA.localeCompare(nameB);
         });
     
    -    container.innerHTML = sortedNodes.map(node => {
    +    container.replaceChildren();
    +
    +    sortedNodes.forEach(node => {
             const nodeId = node.node_id || node.id;
             const nodeIdHex = nodeId ? nodeId.toString(16).padStart(8, '0') : '';
             const displayName = node.display_name || node.name || `Node ${nodeIdHex}`;
     
             // Handle different node data structures
    -        let ageInfo = '';
    -        let roleInfo = '';
    -        let hwInfo = '';
    +        let ageInfo = null;
    +        let roleInfo = null;
    +        let hwInfo = null;
     
             if (node.age_hours !== undefined) {
                 const ageClass = getAgeClass(node.age_hours);
    -            ageInfo = `<span class="age-indicator ${ageClass}">${formatAge(node.age_hours)}</span>`;
    +            ageInfo = el('span', { className: `age-indicator ${ageClass}` }, formatAge(node.age_hours));
             } else if (node.last_seen) {
                 const ageHours = (Date.now() / 1000 - node.last_seen) / 3600;
                 const ageClass = getAgeClass(ageHours);
    -            ageInfo = `<span class="age-indicator ${ageClass}">${formatAge(ageHours)}</span>`;
    +            ageInfo = el('span', { className: `age-indicator ${ageClass}` }, formatAge(ageHours));
             }
     
             if (node.role) {
                 const roleColor = getRoleColor(node.role);
    -            roleInfo = ` <span class="badge badge-sm" style="background-color: ${roleColor}; font-size: 0.6em;">${formatRole(node.role)}</span>`;
    +            roleInfo = el('span', {
    +                className: 'badge badge-sm',
    +                style: { backgroundColor: roleColor, fontSize: '0.6em' }
    +            }, formatRole(node.role));
             }
     
             if (node.hw_model) {
    -            hwInfo = `<small class="text-secondary ms-2">${node.hw_model}</small>`;
    +            hwInfo = el('small', { className: 'text-secondary ms-2' }, node.hw_model);
             } else if (node.connections !== undefined) {
    -            hwInfo = `<small class="text-secondary ms-2">${node.connections} connections</small>`;
    +            hwInfo = el('small', { className: 'text-secondary ms-2' }, `${node.connections} connections`);
             }
     
    -        return `
    -            <div class="node-list-item" onclick="selectNodeFromList(${nodeId})">
    -                <div><strong>${displayName}</strong>${roleInfo}</div>
    -                <small class="text-muted">!${nodeIdHex}</small><br>
    -                ${ageInfo}${hwInfo}
    -            </div>
    -        `;
    -    }).join('');
    +        const item = el('div', { className: 'node-list-item' });
    +        item.dataset.nodeId = String(nodeId);
    +        item.addEventListener('click', () => selectNodeFromList(nodeId));
    +
    +        item.append(
    +            el('div', null,
    +                el('strong', null, displayName),
    +                roleInfo ? textNode(' ') : null,
    +                roleInfo
    +            ),
    +            el('small', { className: 'text-muted' }, `!${nodeIdHex}`),
    +            el('br'),
    +            ageInfo,
    +            hwInfo
    +        );
    +
    +        container.appendChild(item);
    +    });
     
         // Update selection highlighting
         updateNodeListSelection();
    @@ -379,11 +398,8 @@
     // Update node list selection highlighting
     function updateNodeListSelection() {
         document.querySelectorAll('.node-list-item').forEach(item => {
    -        const onclick = item.getAttribute('onclick');
    -        if (onclick) {
    -            const nodeId = parseInt(onclick.match(/\d+/)[0]);
    -            item.classList.toggle('selected', nodeId === selectedNodeId);
    -        }
    +        const nodeId = parseInt(item.dataset.nodeId || '', 10);
    +        item.classList.toggle('selected', nodeId === selectedNodeId);
         });
     }
     
    @@ -400,7 +416,7 @@
     
         // Clear hover details if present
         if (document.getElementById('hoverDetails')) {
    -        document.getElementById('hoverDetails').innerHTML = '<small class="text-muted">Hover over nodes or links for details</small>';
    +        setChildren(document.getElementById('hoverDetails'), el('small', { className: 'text-muted' }, 'Hover over nodes or links for details'));
         }
     }
     
    
  • src/malla/templates/components/traceroute_graph.html+136 117 modified
    @@ -386,10 +386,35 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
     let combinedGraphG = null;
     let pathElements = [];
     
    +function tooltipLine(label, value) {
    +    return el('div', null,
    +        el('strong', null, `${label}:`),
    +        textNode(` ${value}`)
    +    );
    +}
    +
    +function tooltipWithTitle(title, ...lines) {
    +    return el('div', null,
    +        el('div', { className: 'tooltip-title' }, title),
    +        el('div', { className: 'tooltip-content' }, lines)
    +    );
    +}
    +
    +function setGraphTooltip(tooltip, event, contentNode) {
    +    tooltip.node().replaceChildren(contentNode);
    +    tooltip
    +        .style('left', (event.pageX + 15) + 'px')
    +        .style('top', (event.pageY - 10) + 'px');
    +}
    +
     function loadCombinedTracerouteGraph() {
         const container = document.getElementById('combined-traceroute-graph');
         if (!container || !window.packetGraphData || window.packetGraphData.nodes.length === 0) {
    -        container.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100"><p class="text-muted">No graph data available.</p></div>';
    +        setChildren(container,
    +            el('div', { className: 'd-flex align-items-center justify-content-center h-100' },
    +                el('p', { className: 'text-muted' }, 'No graph data available.')
    +            )
    +        );
             return;
         }
     
    @@ -398,7 +423,7 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
         if (loader) loader.remove();
     
         // Clear container
    -    container.innerHTML = '';
    +    container.replaceChildren();
     
         // Set up D3 dimensions
         const containerRect = container.getBoundingClientRect();
    @@ -619,51 +644,51 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
                 const sourceNode = nodes.find(n => n.id === (d.source.id || d.source));
                 const targetNode = nodes.find(n => n.id === (d.target.id || d.target));
     
    -            let content = '';
    +            let contentNode;
                 if (d.type === 'gateway-connection') {
    -                content = `
    -                    <div class="tooltip-title">🎯 Gateway Connection (${d.hop_count || 'N/A'} hops)</div>
    -                    <div class="tooltip-content">
    -                        <strong>From:</strong> ${sourceNode ? sourceNode.label : d.source}<br/>
    -                        <strong>To Gateway:</strong> ${targetNode ? targetNode.label : d.target}<br/>
    -                        <strong>Type:</strong> Gateway heard packet at this hop<br/>
    -                        <strong>Packets:</strong> ${d.packet_ids.join(', ')}<br/>
    -                        <strong>Gateway ID:</strong> ${d.gateway_id || 'Unknown'}
    -                    </div>
    -                `;
    +                contentNode = tooltipWithTitle(
    +                    `Gateway Connection (${d.hop_count || 'N/A'} hops)`,
    +                    tooltipLine('From', sourceNode ? sourceNode.label : d.source),
    +                    tooltipLine('To Gateway', targetNode ? targetNode.label : d.target),
    +                    tooltipLine('Type', 'Gateway heard packet at this hop'),
    +                    tooltipLine('Packets', d.packet_ids.join(', ')),
    +                    tooltipLine('Gateway ID', d.gateway_id || 'Unknown')
    +                );
                 } else if (d.type === 'gateway-connection-zero-hop') {
    -                content = `
    -                    <div class="tooltip-title">📡 Direct Gateway Reception (0 hops)</div>
    -                    <div class="tooltip-content">
    -                        <strong>From:</strong> ${sourceNode ? sourceNode.label : d.source}<br/>
    -                        <strong>To Gateway:</strong> ${targetNode ? targetNode.label : d.target}<br/>
    -                        <strong>Type:</strong> Gateway received packet directly from source<br/>
    -                        <strong>Hops:</strong> 0 (Direct reception)<br/>
    -                        <strong>Note:</strong> No RF routing involved
    -                    </div>
    -                `;
    +                contentNode = tooltipWithTitle(
    +                    'Direct Gateway Reception (0 hops)',
    +                    tooltipLine('From', sourceNode ? sourceNode.label : d.source),
    +                    tooltipLine('To Gateway', targetNode ? targetNode.label : d.target),
    +                    tooltipLine('Type', 'Gateway received packet directly from source'),
    +                    tooltipLine('Hops', '0 (Direct reception)'),
    +                    tooltipLine('Note', 'No RF routing involved')
    +                );
                 } else {
    -                let pathInfo = '';
    +                const lines = [
    +                    tooltipLine('From', sourceNode ? sourceNode.label : d.source),
    +                    tooltipLine('To', targetNode ? targetNode.label : d.target),
    +                    tooltipLine('Observations', d.value),
    +                    tooltipLine('Type', d.is_bidirectional ? 'Bidirectional' : 'Unidirectional')
    +                ];
    +
    +                if (d.avg_snr) {
    +                    lines.push(tooltipLine('Avg SNR', `${d.avg_snr.toFixed(1)} dB`));
    +                }
    +
    +                lines.push(tooltipLine('Unique Packets', String(d.packet_ids.length)));
    +
                     if (d.paths && d.paths.length > 0) {
    -                    pathInfo = `<br><span class="tooltip-badge">Packets: ${d.paths.join(', ')}</span>`;
    +                    lines.push(el('div', null,
    +                        el('strong', null, 'Packets:'),
    +                        textNode(' '),
    +                        d.paths.map((packetId) => el('span', { className: 'tooltip-badge' }, String(packetId)))
    +                    ));
                     }
     
    -                content = `
    -                    <div class="tooltip-title">🔗 RF Link Details</div>
    -                    <div class="tooltip-content">
    -                        <strong>From:</strong> ${sourceNode ? sourceNode.label : d.source}<br/>
    -                        <strong>To:</strong> ${targetNode ? targetNode.label : d.target}<br/>
    -                        <strong>Observations:</strong> ${d.value}<br/>
    -                        <strong>Type:</strong> ${d.is_bidirectional ? 'Bidirectional' : 'Unidirectional'}<br/>
    -                        ${d.avg_snr ? `<strong>Avg SNR:</strong> ${d.avg_snr.toFixed(1)} dB<br/>` : ''}
    -                        <strong>Unique Packets:</strong> ${d.packet_ids.length}${pathInfo}
    -                    </div>
    -                `;
    +                contentNode = tooltipWithTitle('RF Link Details', ...lines);
                 }
     
    -            tooltip.html(content)
    -                .style('left', (event.pageX + 15) + 'px')
    -                .style('top', (event.pageY - 10) + 'px');
    +            setGraphTooltip(tooltip, event, contentNode);
             })
             .on('mouseout', function(event, d) {
                 // Reset link appearance
    @@ -773,14 +798,20 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
     
                 // Find paths that include this node
                 const nodePaths = paths.filter(path => path.nodes.includes(d.id));
    -            let pathInfo = '';
    +            const details = [
    +                tooltipLine('Name', d.label),
    +                tooltipLine('ID', `!${d.id.toString(16).padStart(8, '0')}`),
    +                tooltipLine('Type', `${nodeTypeName}${d.is_gateway ? ' (receives packets)' : ''}`),
    +                tooltipLine('RF Connections', String(allLinks.filter(l => (l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id).length))
    +            ];
    +
                 if (nodePaths.length > 0) {
    -                pathInfo = `<br><strong>Packet Paths:</strong> ${nodePaths.length}<br/>`;
    -                pathInfo += nodePaths.slice(0, 3).map(path =>
    -                    `<span class="tooltip-badge">Packet ${path.packet_id}</span>`
    -                ).join(' ');
    +                details.push(tooltipLine('Packet Paths', String(nodePaths.length)));
    +                details.push(el('div', null,
    +                    nodePaths.slice(0, 3).map((path) => el('span', { className: 'tooltip-badge' }, `Packet ${path.packet_id}`))
    +                ));
                     if (nodePaths.length > 3) {
    -                    pathInfo += ` <span class="tooltip-badge">+${nodePaths.length - 3} more</span>`;
    +                    details.push(el('div', null, el('span', { className: 'tooltip-badge' }, `+${nodePaths.length - 3} more`)));
                     }
                 }
     
    @@ -789,22 +820,11 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
                     l.type === 'gateway-connection' &&
                     ((l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id)
                 );
    -            let gatewayInfo = '';
                 if (gatewayConnections.length > 0) {
    -                gatewayInfo = `<br><strong>Gateway Connections:</strong> ${gatewayConnections.length}`;
    +                details.push(tooltipLine('Gateway Connections', String(gatewayConnections.length)));
                 }
     
    -            tooltip.html(`
    -                <div class="tooltip-title">${nodeTypeIcon} ${nodeTypeName} Details</div>
    -                <div class="tooltip-content">
    -                    <strong>Name:</strong> ${d.label}<br/>
    -                    <strong>ID:</strong> !${d.id.toString(16).padStart(8, '0')}<br/>
    -                    <strong>Type:</strong> ${nodeTypeName}${d.is_gateway ? ' (receives packets)' : ''}<br/>
    -                    <strong>RF Connections:</strong> ${allLinks.filter(l => (l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id).length}${pathInfo}${gatewayInfo}
    -                </div>
    -            `)
    -                .style('left', (event.pageX + 15) + 'px')
    -                .style('top', (event.pageY - 10) + 'px');
    +            setGraphTooltip(tooltip, event, tooltipWithTitle(`${nodeTypeIcon} ${nodeTypeName} Details`, ...details));
             })
             .on('mouseout', function(event, d) {
                 // Reset node appearance
    @@ -955,11 +975,11 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
     function populateLegendPaths(paths, nodes) {
         const container = document.getElementById('legend-paths-container');
         if (!container || !paths || paths.length === 0) {
    -        container.innerHTML = '<small class="text-muted">No paths available</small>';
    +        setChildren(container, el('small', { className: 'text-muted' }, 'No paths available'));
             return;
         }
     
    -    container.innerHTML = '';
    +    container.replaceChildren();
     
         // Sort paths by hop count and packet ID
         const sortedPaths = [...paths].sort((a, b) => {
    @@ -981,19 +1001,14 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
             const gatewayInfo = path.gateway_node_id ?
                 nodes.find(n => n.id === path.gateway_node_id) : null;
     
    -        pathItem.innerHTML = `
    -            <div class="legend-path-color" style="background-color: ${path.color};"></div>
    -            <div class="legend-path-info">
    -                <div class="legend-path-title">
    -                    Packet ${path.packet_id} (${path.hop_count} hops)
    -                </div>
    -                <div class="legend-path-details">
    -                    ${pathNodes.join(' → ')}
    -                    ${gatewayInfo ? ` → 📡 ${gatewayInfo.label}` : ''}
    -                </div>
    -                ${path.avg_snr ? `<div class="legend-path-details">Avg SNR: ${path.avg_snr.toFixed(1)} dB</div>` : ''}
    -            </div>
    -        `;
    +        pathItem.append(
    +            el('div', { className: 'legend-path-color', style: { backgroundColor: path.color } }),
    +            el('div', { className: 'legend-path-info' },
    +                el('div', { className: 'legend-path-title' }, `Packet ${path.packet_id} (${path.hop_count} hops)`),
    +                el('div', { className: 'legend-path-details' }, `${pathNodes.join(' -> ')}${gatewayInfo ? ` -> Gateway ${gatewayInfo.label}` : ''}`),
    +                path.avg_snr ? el('div', { className: 'legend-path-details' }, `Avg SNR: ${path.avg_snr.toFixed(1)} dB`) : null
    +            )
    +        );
     
             // Add hover interactions
             pathItem.addEventListener('mouseenter', () => {
    @@ -1056,51 +1071,55 @@ <h6 class="text-muted mb-2" style="font-size: 0.85rem;">Packet Paths</h6>
         modal.className = 'modal-content';
         modal.onclick = (e) => e.stopPropagation(); // Prevent closing when clicking inside modal
     
    -    const snrBadge = linkData.avg_snr ?
    -        `<span class="badge ${linkData.avg_snr > -10 ? 'bg-success' : linkData.avg_snr > -20 ? 'bg-warning' : 'bg-danger'}">${linkData.avg_snr.toFixed(1)} dB</span>` :
    -        '<span class="badge bg-secondary">N/A</span>';
    +    const snrBadge = linkData.avg_snr
    +        ? badge(`${linkData.avg_snr.toFixed(1)} dB`, linkData.avg_snr > -10 ? 'bg-success' : linkData.avg_snr > -20 ? 'bg-warning' : 'bg-danger')
    +        : badge('N/A', 'bg-secondary');
     
    -    modal.innerHTML = `
    -        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
    -            <h5 style="margin: 0;"><i class="bi bi-arrow-left-right"></i> RF Link Details</h5>
    -            <button onclick="document.querySelector('.modal-backdrop').remove()" class="btn-close btn-close-white" aria-label="Close"></button>
    -        </div>
    -        <div>
    -            <div class="row mb-3">
    -                <div class="col-6">
    -                    <strong>From:</strong><br>
    -                    <span class="text-primary">${sourceNode.label}</span>
    -                </div>
    -                <div class="col-6">
    -                    <strong>To:</strong><br>
    -                    <span class="text-primary">${targetNode.label}</span>
    -                </div>
    -            </div>
    -            <div class="row mb-3">
    -                <div class="col-4">
    -                    <strong>Observations:</strong><br>
    -                    <span class="badge bg-info">${linkData.value}</span>
    -                </div>
    -                <div class="col-4">
    -                    <strong>Type:</strong><br>
    -                    <span class="badge ${linkData.is_bidirectional ? 'bg-success' : 'bg-warning'}">${linkData.is_bidirectional ? 'Bidirectional' : 'Unidirectional'}</span>
    -                </div>
    -                <div class="col-4">
    -                    <strong>Avg SNR:</strong><br>
    -                    ${snrBadge}
    -                </div>
    -            </div>
    -            <div class="mb-3">
    -                <strong>Packets:</strong> ${linkData.packet_ids.join(', ')}
    -            </div>
    -            <div style="margin-top: 20px;">
    -                <a href="#" onclick="openTracerouteHops(${sourceNode.id}, ${targetNode.id}); return false;"
    -                   class="btn btn-outline-primary btn-sm" title="View hops between these nodes">
    -                    <i class="bi bi-diagram-3"></i> Hops
    -                </a>
    -            </div>
    -        </div>
    -    `;
    +    const closeButton = el('button', {
    +        className: 'btn-close btn-close-white',
    +        ariaLabel: 'Close',
    +        type: 'button'
    +    });
    +    closeButton.addEventListener('click', () => backdrop.remove());
    +
    +    const hopsButton = buttonLink({
    +        href: '#',
    +        className: 'btn btn-outline-primary btn-sm',
    +        title: 'View hops between these nodes',
    +        iconClass: 'bi bi-diagram-3',
    +        text: 'Hops'
    +    });
    +    hopsButton.addEventListener('click', (event) => {
    +        event.preventDefault();
    +        openTracerouteHops(sourceNode.id, targetNode.id);
    +    });
    +
    +    setChildren(modal,
    +        el('div', {
    +            style: {
    +                display: 'flex',
    +                justifyContent: 'space-between',
    +                alignItems: 'center',
    +                marginBottom: '20px'
    +            }
    +        },
    +            el('h5', { style: { margin: '0' } }, icon('bi bi-arrow-left-right'), textNode(' RF Link Details')),
    +            closeButton
    +        ),
    +        el('div', null,
    +            el('div', { className: 'row mb-3' },
    +                el('div', { className: 'col-6' }, el('strong', null, 'From:'), el('br'), el('span', { className: 'text-primary' }, sourceNode.label)),
    +                el('div', { className: 'col-6' }, el('strong', null, 'To:'), el('br'), el('span', { className: 'text-primary' }, targetNode.label))
    +            ),
    +            el('div', { className: 'row mb-3' },
    +                el('div', { className: 'col-4' }, el('strong', null, 'Observations:'), el('br'), badge(String(linkData.value), 'bg-info')),
    +                el('div', { className: 'col-4' }, el('strong', null, 'Type:'), el('br'), badge(linkData.is_bidirectional ? 'Bidirectional' : 'Unidirectional', linkData.is_bidirectional ? 'bg-success' : 'bg-warning')),
    +                el('div', { className: 'col-4' }, el('strong', null, 'Avg SNR:'), el('br'), snrBadge)
    +            ),
    +            el('div', { className: 'mb-3' }, el('strong', null, 'Packets:'), textNode(` ${linkData.packet_ids.join(', ')}`)),
    +            el('div', { style: { marginTop: '20px' } }, hopsButton)
    +        )
    +    );
     
         backdrop.appendChild(modal);
         document.body.appendChild(backdrop);
    
  • src/malla/templates/dashboard.html+12 11 modified
    @@ -783,16 +783,12 @@ <h6 class="card-title mb-0"><i class="bi bi-reception-4"></i> Signal Quality</h6
         const tbody = document.querySelector('#topNodesTable tbody');
         if (!tbody) return;
     
    -    tbody.innerHTML = '';
    +    tbody.replaceChildren();
     
         topNodes.slice(0, 10).forEach(node => {
    -        const row = document.createElement('tr');
    -
    -        // Node name with link
             const nodeName = node.display_name || `!${node.node_id.toString(16).padStart(8, '0')}`;
    -        const nodeLink = `/node/${node.node_id}`;
    +        const row = document.createElement('tr');
     
    -        // Signal quality indicator
             let signalClass = 'text-muted';
             let signalText = 'N/A';
             if (node.avg_rssi && node.avg_rssi !== 0) {
    @@ -811,11 +807,16 @@ <h6 class="card-title mb-0"><i class="bi bi-reception-4"></i> Signal Quality</h6
                 }
             }
     
    -        row.innerHTML = `
    -            <td><a href="${nodeLink}" class="text-decoration-none">${nodeName}</a></td>
    -            <td><span class="badge bg-primary">${node.packet_count || 0}</span></td>
    -            <td><span class="${signalClass}">${signalText}</span></td>
    -        `;
    +        row.append(
    +            el('td', null,
    +                el('a', {
    +                    href: safePath(`/node/${encodeURIComponent(String(node.node_id))}`),
    +                    className: 'text-decoration-none'
    +                }, nodeName)
    +            ),
    +            el('td', null, badge(String(node.packet_count || 0), 'bg-primary')),
    +            el('td', null, el('span', { className: signalClass }, signalText))
    +        );
     
             tbody.appendChild(row);
         });
    
  • src/malla/templates/line_of_sight.html+103 65 modified
    @@ -487,7 +487,7 @@ <h6><i class="bi bi-award"></i> Data Attribution</h6>
                             const distanceSpan = document.createElement('span');
                             distanceSpan.className = 'ms-2 distance-info';
                             distanceSpan.style.color = 'var(--bs-success)';
    -                        distanceSpan.innerHTML = `📍 ${node._distance.toFixed(1)} km`;
    +                        distanceSpan.textContent = `📍 ${node._distance.toFixed(1)} km`;
                             details.appendChild(distanceSpan);
                         }
                     }
    @@ -707,71 +707,81 @@ <h6><i class="bi bi-award"></i> Data Attribution</h6>
         const toTerrainElevation = geoPoints[geoPoints.length - 1].elevation;
     
         const useNodeElevation = document.getElementById('useNodeElevationToggle').checked;
    +    const elevationModeDescription = document.getElementById('elevationModeDescription');
    +    const analysisStatusBadge = document.getElementById('analysisStatusBadge');
    +    const analysisMetrics = document.getElementById('analysisMetrics');
     
         // Analyze line of sight first (needed for table)
         const losAnalysis = checkLineOfSight(geoPoints, fromAltitude, toAltitude, useNodeElevation);
     
         // Display link information as table with node names as columns and info as rows
         const distanceKm = (elevationData.metrics.distance / 1000).toFixed(2);
    -    document.getElementById('linkInfoHeader').innerHTML = `
    -        <th style="width: 25%; border-bottom: 2px solid var(--bs-border-color);"></th>
    -        <th class="text-center" style="width: 37.5%; border-bottom: 2px solid var(--bs-border-color);">
    -            <i class="bi bi-geo-alt-fill text-success"></i> ${fromName}
    -        </th>
    -        <th class="text-center" style="width: 37.5%; border-bottom: 2px solid var(--bs-border-color);">
    -            <i class="bi bi-geo-alt-fill text-danger"></i> ${toName}
    -        </th>
    -    `;
    -    document.getElementById('linkInfoTable').innerHTML = `
    -        <tr>
    -            <td><strong>Node Altitude</strong></td>
    -            <td class="text-center">${fromAltitude.toFixed(0)} m</td>
    -            <td class="text-center">${toAltitude.toFixed(0)} m</td>
    -        </tr>
    -        <tr>
    -            <td><strong>Terrain Elevation</strong></td>
    -            <td class="text-center text-muted">${fromTerrainElevation.toFixed(0)} m</td>
    -            <td class="text-center text-muted">${toTerrainElevation.toFixed(0)} m</td>
    -        </tr>
    -        <tr style="border-top: 2px solid var(--bs-border-color);">
    -            <td><strong>Distance</strong></td>
    -            <td colspan="2" class="text-center" style="font-size: 1.2rem; font-weight: bold; color: var(--bs-primary);">
    -                ${distanceKm} km
    -            </td>
    -        </tr>
    -        <tr>
    -            <td><strong>Min Clearance</strong></td>
    -            <td colspan="2" class="text-center">${losAnalysis.minClearance.toFixed(1)} m</td>
    -        </tr>
    -        <tr>
    -            <td><strong>Obstacles</strong></td>
    -            <td colspan="2" class="text-center">${losAnalysis.obstacles.length}</td>
    -        </tr>
    -        <tr>
    -            <td><strong>Terrain Rise</strong></td>
    -            <td colspan="2" class="text-center">${elevationData.metrics.climb.toFixed(0)} m</td>
    -        </tr>
    -        <tr>
    -            <td><strong>Terrain Fall</strong></td>
    -            <td colspan="2" class="text-center">${Math.abs(elevationData.metrics.descent).toFixed(0)} m</td>
    -        </tr>
    -    `;
    +    const linkInfoHeader = document.getElementById('linkInfoHeader');
    +    const linkInfoTable = document.getElementById('linkInfoTable');
    +
    +    setChildren(linkInfoHeader,
    +        el('th', { style: { width: '25%', borderBottom: '2px solid var(--bs-border-color)' } }),
    +        el('th', {
    +            className: 'text-center',
    +            style: { width: '37.5%', borderBottom: '2px solid var(--bs-border-color)' }
    +        }, icon('bi bi-geo-alt-fill text-success'), textNode(' '), fromName),
    +        el('th', {
    +            className: 'text-center',
    +            style: { width: '37.5%', borderBottom: '2px solid var(--bs-border-color)' }
    +        }, icon('bi bi-geo-alt-fill text-danger'), textNode(' '), toName)
    +    );
    +
    +    const tableRow = (label, left, right, options = {}) => el('tr', options.rowAttrs || null,
    +        el('td', null, el('strong', null, label)),
    +        options.singleValue
    +            ? el('td', {
    +                colspan: '2',
    +                className: options.valueClass || 'text-center',
    +                style: options.valueStyle || null
    +            }, left)
    +            : [
    +                el('td', { className: options.leftClass || 'text-center' }, left),
    +                el('td', { className: options.rightClass || 'text-center' }, right)
    +            ]
    +    );
    +
    +    setChildren(linkInfoTable,
    +        tableRow('Node Altitude', `${fromAltitude.toFixed(0)} m`, `${toAltitude.toFixed(0)} m`),
    +        tableRow('Terrain Elevation', `${fromTerrainElevation.toFixed(0)} m`, `${toTerrainElevation.toFixed(0)} m`, {
    +            leftClass: 'text-center text-muted',
    +            rightClass: 'text-center text-muted'
    +        }),
    +        tableRow('Distance', `${distanceKm} km`, null, {
    +            singleValue: true,
    +            rowAttrs: { style: { borderTop: '2px solid var(--bs-border-color)' } },
    +            valueStyle: { fontSize: '1.2rem', fontWeight: 'bold', color: 'var(--bs-primary)' }
    +        }),
    +        tableRow('Min Clearance', `${losAnalysis.minClearance.toFixed(1)} m`, null, { singleValue: true }),
    +        tableRow('Obstacles', String(losAnalysis.obstacles.length), null, { singleValue: true }),
    +        tableRow('Terrain Rise', `${elevationData.metrics.climb.toFixed(0)} m`, null, { singleValue: true }),
    +        tableRow('Terrain Fall', `${Math.abs(elevationData.metrics.descent).toFixed(0)} m`, null, { singleValue: true })
    +    );
     
         // Update elevation mode description
         const startElev = useNodeElevation ? (fromAltitude || fromTerrainElevation) : fromTerrainElevation;
         const endElev = useNodeElevation ? (toAltitude || toTerrainElevation) : toTerrainElevation;
    -    document.getElementById('elevationModeDescription').innerHTML = useNodeElevation
    +    elevationModeDescription.textContent = useNodeElevation
             ? `Using node altitudes (${startElev.toFixed(0)}m → ${endElev.toFixed(0)}m) as elevation above sea level.`
             : `Using terrain elevation (${startElev.toFixed(0)}m → ${endElev.toFixed(0)}m) at node locations. Use this when node altitude values are incorrect.`;
     
    -    const statusBadge = losAnalysis.clear
    -        ? '<span class="badge bg-success status-badge"><i class="bi bi-check-circle-fill"></i> Clear Line of Sight</span>'
    -        : '<span class="badge bg-warning text-dark status-badge"><i class="bi bi-exclamation-triangle-fill"></i> Potential Obstructions</span>';
    -
    -    document.getElementById('analysisStatusBadge').innerHTML = statusBadge;
    +    setChildren(
    +        analysisStatusBadge,
    +        el('span', {
    +            className: losAnalysis.clear
    +                ? 'badge bg-success status-badge'
    +                : 'badge bg-warning text-dark status-badge'
    +        },
    +        icon(losAnalysis.clear ? 'bi bi-check-circle-fill' : 'bi bi-exclamation-triangle-fill'),
    +        textNode(losAnalysis.clear ? ' Clear Line of Sight' : ' Potential Obstructions'))
    +    );
     
         // Clear metrics display (now integrated into the table)
    -    document.getElementById('analysisMetrics').innerHTML = '';
    +    analysisMetrics.replaceChildren();
     
         // Update map
         updateMap(fromNode, toNode, geoPoints, losAnalysis.obstacles);
    @@ -781,11 +791,29 @@ <h6><i class="bi bi-award"></i> Data Attribution</h6>
     
         // Update attribution
         const dataSet = elevationData.dataSet;
    -    document.getElementById('dataSourceAttribution').innerHTML = `
    -        <p class="mb-1"><strong>Elevation Data:</strong> ${dataSet.description} (${dataSet.resolutionMeters}m resolution)</p>
    -        ${dataSet.publicUrl ? `<p class="mb-0"><a href="${dataSet.publicUrl}" target="_blank" rel="noopener">More information about this dataset <i class="bi bi-box-arrow-up-right"></i></a></p>` : ''}
    -    `;
    -    document.getElementById('demDataSource').innerHTML = `<strong>Elevation Data Provider</strong> - ${dataSet.description}`;
    +    const dataSourceAttribution = document.getElementById('dataSourceAttribution');
    +    const demDataSource = document.getElementById('demDataSource');
    +    const publicUrl = safeUrl(dataSet.publicUrl);
    +
    +    setChildren(dataSourceAttribution,
    +        el('p', { className: 'mb-1' },
    +            el('strong', null, 'Elevation Data:'),
    +            textNode(` ${dataSet.description} (${dataSet.resolutionMeters}m resolution)`)
    +        ),
    +        publicUrl
    +            ? el('p', { className: 'mb-0' },
    +                el('a', {
    +                    href: publicUrl,
    +                    target: '_blank',
    +                    rel: 'noopener noreferrer'
    +                }, 'More information about this dataset ', icon('bi bi-box-arrow-up-right'))
    +            )
    +            : null
    +    );
    +    setChildren(demDataSource,
    +        el('strong', null, 'Elevation Data Provider'),
    +        textNode(` - ${dataSet.description}`)
    +    );
     }
     
     // Update map with line and obstacles
    @@ -815,8 +843,14 @@ <h6><i class="bi bi-award"></i> Data Attribution</h6>
             })
         }).addTo(map);
     
    -    startMarker.bindPopup(`<strong>From:</strong> ${fromNode.long_name || fromNode.short_name || fromNode.hex_id}`);
    -    endMarker.bindPopup(`<strong>To:</strong> ${toNode.long_name || toNode.short_name || toNode.hex_id}`);
    +    startMarker.bindPopup(el('div', null,
    +        el('strong', null, 'From:'),
    +        textNode(` ${fromNode.long_name || fromNode.short_name || fromNode.hex_id}`)
    +    ));
    +    endMarker.bindPopup(el('div', null,
    +        el('strong', null, 'To:'),
    +        textNode(` ${toNode.long_name || toNode.short_name || toNode.hex_id}`)
    +    ));
     
         // Draw line between nodes using the elevation profile points
         const linePoints = geoPoints.map(p => [p.latitude, p.longitude]);
    @@ -909,13 +943,17 @@ <h6><i class="bi bi-award"></i> Data Attribution</h6>
                 color: '#dc3545',
                 weight: 2,
                 fillOpacity: 0.9
    -        }).addTo(map).bindPopup(`
    -            <strong>Obstacle ${index + 1}</strong><br>
    -            Distance: ${(obstacle.distance / 1000).toFixed(2)} km<br>
    -            Elevation: ${obstacle.elevation.toFixed(0)} m<br>
    -            Clearance: ${obstacle.clearance.toFixed(1)} m<br>
    -            Required: ${obstacle.requiredClearance.toFixed(1)} m
    -        `);
    +        }).addTo(map).bindPopup(el('div', null,
    +            el('strong', null, `Obstacle ${index + 1}`),
    +            el('br'),
    +            textNode(`Distance: ${(obstacle.distance / 1000).toFixed(2)} km`),
    +            el('br'),
    +            textNode(`Elevation: ${obstacle.elevation.toFixed(0)} m`),
    +            el('br'),
    +            textNode(`Clearance: ${obstacle.clearance.toFixed(1)} m`),
    +            el('br'),
    +            textNode(`Required: ${obstacle.requiredClearance.toFixed(1)} m`)
    +        ));
         });
     
         // Fit map to bounds using all points along the line (not just the nodes)
    
  • src/malla/templates/longest_links.html+76 79 modified
    @@ -349,119 +349,116 @@ <h4 class="mt-3">No Complete Paths Found</h4>
             const noDataDiv = document.getElementById('direct-no-data');
     
             if (links.length === 0) {
    -            tableBody.innerHTML = '';
    +            tableBody.replaceChildren();
                 noDataDiv.style.display = 'block';
                 return;
             }
     
             noDataDiv.style.display = 'none';
    -        
    -        let html = '';
    +        tableBody.replaceChildren();
    +
             links.forEach((link, index) => {
                 const snrBadgeClass = getSNRBadgeClass(link.avg_snr);
                 const snrText = link.avg_snr !== null ? `${link.avg_snr.toFixed(1)} dB` : 'N/A';
    -            
    -            // Handle null distance values
                 const distanceText = link.distance_km !== null ? `${link.distance_km.toFixed(2)} km` : 'Unknown';
                 const isLongDistance = link.distance_km !== null && link.distance_km >= 10;
    -            
    -            html += `
    -                <tr>
    -                    <td><strong>${index + 1}</strong></td>
    -                    <td>
    -                        <a href="/node/${link.from_node_id}" class="node-link" title="${link.from_node_name}">
    -                            ${link.from_node_name}
    -                        </a>
    -                    </td>
    -                    <td>
    -                        <a href="/node/${link.to_node_id}" class="node-link" title="${link.to_node_name}">
    -                            ${link.to_node_name}
    -                        </a>
    -                    </td>
    -                    <td class="${isLongDistance ? 'text-warning fw-bold' : ''}">${distanceText}</td>
    -                    <td><span class="badge ${snrBadgeClass}">${snrText}</span></td>
    -                    <td class="text-center">${link.traceroute_count}</td>
    -                    <td>
    -                        <a href="${link.packet_url}" class="btn btn-sm btn-outline-primary" title="View packet details">
    -                            <i class="fas fa-eye"></i> Packet ${link.packet_id}
    -                        </a>
    -                        <a href="/traceroute-hops?from_node=${link.from_node_id}&to_node=${link.to_node_id}" 
    -                           class="btn btn-sm btn-outline-info ms-1" title="Analyze this link">
    -                            <i class="fas fa-route"></i> Analyze
    -                        </a>
    -                    </td>
    -                </tr>
    -            `;
    -        });
     
    -        tableBody.innerHTML = html;
    +            const packetUrl = safeUrl(link.packet_url);
    +            const row = el('tr', null,
    +                el('td', null, el('strong', null, String(index + 1))),
    +                el('td', null, nodeLink(link.from_node_id, link.from_node_name, { className: 'node-link', tooltip: false, title: link.from_node_name || 'View node details' })),
    +                el('td', null, nodeLink(link.to_node_id, link.to_node_name, { className: 'node-link', tooltip: false, title: link.to_node_name || 'View node details' })),
    +                el('td', { className: isLongDistance ? 'text-warning fw-bold' : '' }, distanceText),
    +                el('td', null, badge(snrText, snrBadgeClass)),
    +                el('td', { className: 'text-center' }, String(link.traceroute_count)),
    +                el('td', null,
    +                    packetUrl
    +                        ? buttonLink({
    +                            href: packetUrl,
    +                            className: 'btn btn-sm btn-outline-primary',
    +                            title: 'View packet details',
    +                            iconClass: 'fas fa-eye',
    +                            text: `Packet ${link.packet_id}`
    +                        })
    +                        : el('span', { className: 'text-muted' }, 'Packet unavailable'),
    +                    textNode(' '),
    +                    buttonLink({
    +                        href: safePath('/traceroute-hops', { from_node: link.from_node_id, to_node: link.to_node_id }),
    +                        className: 'btn btn-sm btn-outline-info ms-1',
    +                        title: 'Analyze this link',
    +                        iconClass: 'fas fa-route',
    +                        text: 'Analyze'
    +                    })
    +                )
    +            );
    +
    +            tableBody.appendChild(row);
    +        });
         }
     
         function displayIndirectLinks(links) {
             const tableBody = document.getElementById('indirect-links-table');
             const noDataDiv = document.getElementById('indirect-no-data');
     
             if (links.length === 0) {
    -            tableBody.innerHTML = '';
    +            tableBody.replaceChildren();
                 noDataDiv.style.display = 'block';
                 return;
             }
     
             noDataDiv.style.display = 'none';
    -        
    -        let html = '';
    +        tableBody.replaceChildren();
    +
             links.forEach((link, index) => {
                 const snrBadgeClass = getSNRBadgeClass(link.avg_snr);
                 const snrText = link.avg_snr !== null ? `${link.avg_snr.toFixed(1)} dB` : 'N/A';
    -            
    -            // Handle null distance values
                 const distanceText = link.total_distance_km !== null ? `${link.total_distance_km.toFixed(2)} km` : 'Unknown';
                 const isVeryLongPath = link.total_distance_km !== null && link.total_distance_km >= 20;
    -            
    -            // Format route display
    -            let routeDisplay = '';
    +
    +            let routeDisplay;
                 if (link.route_preview && link.route_preview.length > 0) {
                     const maxNodes = 3;
    -                const preview = link.route_preview.slice(0, maxNodes);
    -                routeDisplay = preview.join(' → ');
    +                const preview = link.route_preview.slice(0, maxNodes).map((name) => textNode(name));
    +                routeDisplay = joinNodes(preview, ' -> ');
                     if (link.route_preview.length > maxNodes) {
    -                    routeDisplay += ` (+${link.route_preview.length - maxNodes} more)`;
    +                    routeDisplay.appendChild(textNode(` (+${link.route_preview.length - maxNodes} more)`));
                     }
                 } else {
    -                routeDisplay = 'Direct route';
    +                routeDisplay = textNode('Direct route');
                 }
    -            
    -            html += `
    -                <tr>
    -                    <td><strong>${index + 1}</strong></td>
    -                    <td>
    -                        <a href="/node/${link.from_node_id}" class="node-link" title="${link.from_node_name}">
    -                            ${link.from_node_name}
    -                        </a>
    -                    </td>
    -                    <td>
    -                        <a href="/node/${link.to_node_id}" class="node-link" title="${link.to_node_name}">
    -                            ${link.to_node_name}
    -                        </a>
    -                    </td>
    -                    <td class="${isVeryLongPath ? 'text-warning fw-bold' : ''}">${distanceText}</td>
    -                    <td class="text-center">${link.hop_count}</td>
    -                    <td><span class="badge ${snrBadgeClass}">${snrText}</span></td>
    -                    <td><small class="text-muted route-preview">${routeDisplay}</small></td>
    -                    <td>
    -                        <a href="${link.packet_url}" class="btn btn-sm btn-outline-primary" title="View packet details">
    -                            <i class="fas fa-eye"></i> Packet ${link.packet_id}
    -                        </a>
    -                        <a href="/traceroute-hops?from_node=${link.from_node_id}&to_node=${link.to_node_id}" 
    -                           class="btn btn-sm btn-outline-info ms-1" title="Analyze this path">
    -                            <i class="fas fa-route"></i> Analyze
    -                        </a>
    -                    </td>
    -                </tr>
    -            `;
    -        });
     
    -        tableBody.innerHTML = html;
    +            const packetUrl = safeUrl(link.packet_url);
    +            const row = el('tr', null,
    +                el('td', null, el('strong', null, String(index + 1))),
    +                el('td', null, nodeLink(link.from_node_id, link.from_node_name, { className: 'node-link', tooltip: false, title: link.from_node_name || 'View node details' })),
    +                el('td', null, nodeLink(link.to_node_id, link.to_node_name, { className: 'node-link', tooltip: false, title: link.to_node_name || 'View node details' })),
    +                el('td', { className: isVeryLongPath ? 'text-warning fw-bold' : '' }, distanceText),
    +                el('td', { className: 'text-center' }, String(link.hop_count)),
    +                el('td', null, badge(snrText, snrBadgeClass)),
    +                el('td', null, el('small', { className: 'text-muted route-preview' }, routeDisplay)),
    +                el('td', null,
    +                    packetUrl
    +                        ? buttonLink({
    +                            href: packetUrl,
    +                            className: 'btn btn-sm btn-outline-primary',
    +                            title: 'View packet details',
    +                            iconClass: 'fas fa-eye',
    +                            text: `Packet ${link.packet_id}`
    +                        })
    +                        : el('span', { className: 'text-muted' }, 'Packet unavailable'),
    +                    textNode(' '),
    +                    buttonLink({
    +                        href: safePath('/traceroute-hops', { from_node: link.from_node_id, to_node: link.to_node_id }),
    +                        className: 'btn btn-sm btn-outline-info ms-1',
    +                        title: 'Analyze this path',
    +                        iconClass: 'fas fa-route',
    +                        text: 'Analyze'
    +                    })
    +                )
    +            );
    +
    +            tableBody.appendChild(row);
    +        });
         }
     
         function getSNRBadgeClass(snr) {
    @@ -483,4 +480,4 @@ <h4 class="mt-3">No Complete Paths Found</h4>
             return `${Math.floor(diff / 2592000)}mo ago`;
         }
     </script>
    -{% endblock %} 
    \ No newline at end of file
    +{% endblock %} 
    
  • src/malla/templates/macros.html+3 4 modified
    @@ -208,9 +208,8 @@
                        data-node-id="{{ gateway_id }}"
                        data-bs-toggle="tooltip"
                        data-bs-placement="top"
    -                   data-bs-html="true"
    -                   data-bs-title="View this specific reception<br>Gateway: {{ display_name }}">
    -                    {{ display_name }}
    +                   title="View this specific reception - Gateway: {{ display_name }}">
    +                     {{ display_name }}
                     </a>
                 {% else %}
                     {# Link to node page (original behavior) #}
    @@ -356,4 +355,4 @@
                 </div>
             </div>
         </div>
    -{% endmacro %} 
    \ No newline at end of file
    +{% endmacro %} 
    
  • src/malla/templates/map.html+122 142 modified
    @@ -642,8 +642,10 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
                 if (data.channels) {
                     const select = document.getElementById('channelFilter');
                     const allOption = select.querySelector('option[value=""]');
    -                select.innerHTML = '';
    -                select.appendChild(allOption);
    +                select.replaceChildren();
    +                if (allOption) {
    +                    select.appendChild(allOption);
    +                }
                     data.channels.forEach((ch) => {
                         const option = document.createElement('option');
                         option.value = ch;
    @@ -710,7 +712,7 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
                 }
     
                 return new L.DivIcon({
    -                html: '<div><span>' + childCount + '</span></div>',
    +                html: el('div', null, el('span', null, String(childCount))),
                     className: 'marker-cluster ' + className,
                     iconSize: new L.Point(40, 40)
                 });
    @@ -909,11 +911,10 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
     
         const markerIcon = L.divIcon({
             className: 'custom-node-marker',
    -        html: `
    -            <div class="node-marker-container" style="background-color: ${roleColor};">
    -                <div class="node-marker-label">${displayText}</div>
    -            </div>
    -        `,
    +        html: el('div', {
    +            className: 'node-marker-container',
    +            style: { backgroundColor: roleColor }
    +        }, el('div', { className: 'node-marker-label' }, displayText)),
             iconSize: [40, 40],
             iconAnchor: [20, 20]
         });
    @@ -940,24 +941,55 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
         const ageClass = getAgeClass(ageHours);
         const roleColor = getRoleColor(node.role);
     
    -    return `
    -        <div class="node-marker-info">
    -            <div class="node-marker-title">${node.display_name}</div>
    -            <div><strong>ID:</strong> !${nodeIdHex}</div>
    -            ${node.short_name ? `<div><strong>Short Name:</strong> ${node.short_name}</div>` : ''}
    -            ${node.role ? `<div><strong>Role:</strong> <span class="badge" style="background-color: ${roleColor};">${node.role}</span></div>` : ''}
    -            <div><strong>Location:</strong> ${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}</div>
    -            ${node.altitude ? `<div><strong>Altitude:</strong> ${node.altitude}m</div>` : ''}
    -            ${node.hw_model ? `<div><strong>Hardware:</strong> ${node.hw_model}</div>` : ''}
    -            <div><strong>Age:</strong> <span class="age-indicator ${ageClass}">${formatAge(ageHours)}</span></div>
    -
    -            <div class="mt-2">
    -                <button class="btn btn-sm btn-primary" onclick="viewNodeDetails(${node.node_id})">
    -                    View Details
    -                </button>
    -            </div>
    -        </div>
    -    `;
    +    const button = el('button', { className: 'btn btn-sm btn-primary', type: 'button' }, 'View Details');
    +    button.addEventListener('click', () => viewNodeDetails(node.node_id));
    +
    +    return el('div', { className: 'node-marker-info' },
    +        el('div', { className: 'node-marker-title' }, node.display_name),
    +        el('div', null, el('strong', null, 'ID:'), textNode(` !${nodeIdHex}`)),
    +        node.short_name ? el('div', null, el('strong', null, 'Short Name:'), textNode(` ${node.short_name}`)) : null,
    +        node.role ? el('div', null,
    +            el('strong', null, 'Role:'),
    +            textNode(' '),
    +            el('span', { className: 'badge', style: { backgroundColor: roleColor } }, node.role)
    +        ) : null,
    +        el('div', null, el('strong', null, 'Location:'), textNode(` ${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}`)),
    +        node.altitude ? el('div', null, el('strong', null, 'Altitude:'), textNode(` ${node.altitude}m`)) : null,
    +        node.hw_model ? el('div', null, el('strong', null, 'Hardware:'), textNode(` ${node.hw_model}`)) : null,
    +        el('div', null,
    +            el('strong', null, 'Age:'),
    +            textNode(' '),
    +            el('span', { className: `age-indicator ${ageClass}` }, formatAge(ageHours))
    +        ),
    +        el('div', { className: 'mt-2' }, button)
    +    );
    +}
    +
    +function buildNodeListItem(node, selectHandler) {
    +    const nodeIdHex = node.node_id.toString(16).padStart(8, '0');
    +    const ageHours = (Date.now() / 1000 - node.timestamp) / 3600;
    +    const ageClass = getAgeClass(ageHours);
    +    const roleColor = getRoleColor(node.role);
    +    const item = el('div', { className: 'node-list-item' });
    +    item.dataset.nodeId = String(node.node_id);
    +    item.addEventListener('click', () => selectHandler(node.node_id));
    +
    +    item.append(
    +        el('div', null,
    +            el('strong', null, node.display_name),
    +            node.role ? textNode(' ') : null,
    +            node.role ? el('span', {
    +                className: 'badge badge-sm',
    +                style: { backgroundColor: roleColor, fontSize: '0.6em' }
    +            }, formatRole(node.role)) : null
    +        ),
    +        el('small', { className: 'text-muted' }, `!${nodeIdHex}`),
    +        el('br'),
    +        el('span', { className: `age-indicator ${ageClass}` }, formatAge(ageHours)),
    +        node.hw_model ? el('small', { className: 'text-secondary ms-2' }, node.hw_model) : null
    +    );
    +
    +    return item;
     }
     
     // Unified node search and display
    @@ -1002,28 +1034,17 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
     
         if (nodes.length === 0) {
             const message = isSearchResults ? 'No nodes found' : 'No nodes available';
    -        container.innerHTML = `<div class="text-center text-muted py-4"><small>${message}</small></div>`;
    +        setChildren(container,
    +            el('div', { className: 'text-center text-muted py-4' }, el('small', null, message))
    +        );
             return;
         }
     
         // Sort nodes by name
         const sortedNodes = [...nodes].sort((a, b) => a.display_name.localeCompare(b.display_name));
     
    -    container.innerHTML = sortedNodes.map(node => {
    -        const nodeIdHex = node.node_id.toString(16).padStart(8, '0');
    -        const ageHours = (Date.now() / 1000 - node.timestamp) / 3600;
    -        const ageClass = getAgeClass(ageHours);
    -        const roleColor = getRoleColor(node.role);
    -
    -        return `
    -            <div class="node-list-item" onclick="selectNodeFromList(${node.node_id})">
    -                <div><strong>${node.display_name}</strong> ${node.role ? `<span class="badge badge-sm" style="background-color: ${roleColor}; font-size: 0.6em;">${formatRole(node.role)}</span>` : ''}</div>
    -                <small class="text-muted">!${nodeIdHex}</small><br>
    -                <span class="age-indicator ${ageClass}">${formatAge(ageHours)}</span>
    -                ${node.hw_model ? `<small class="text-secondary ms-2">${node.hw_model}</small>` : ''}
    -            </div>
    -        `;
    -    }).join('');
    +    container.replaceChildren();
    +    sortedNodes.forEach((node) => container.appendChild(buildNodeListItem(node, selectNodeFromList)));
     
         // Update selection highlighting
         updateNodeListSelection();
    @@ -1072,63 +1093,34 @@ <h6><i class="bi bi-stack"></i> Hop Depth</h6>
         const ageClass = getAgeClass(ageHours);
         const roleColor = getRoleColor(node.role);
     
    -    const content = `
    -        <div class="row">
    -            <div class="col-12">
    -                <h6>${node.display_name}</h6>
    -                <p class="text-muted">Node ID: !${nodeIdHex}</p>
    -                ${node.short_name ? `<p class="text-muted">Short: ${node.short_name}</p>` : ''}
    -            </div>
    -        </div>
    -        <div class="row">
    -            <div class="col-6">
    -                <strong>Location:</strong><br>
    -                <small>${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}</small>
    -            </div>
    -            <div class="col-6">
    -                <strong>Age:</strong><br>
    -                <span class="age-indicator ${ageClass}">${formatAge(ageHours)}</span>
    -            </div>
    -        </div>
    -        ${node.role ? `
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <strong>Role:</strong><br>
    -                <span class="badge" style="background-color: ${roleColor};">${node.role}</span>
    -            </div>
    -            ${node.altitude ? `
    -            <div class="col-6">
    -                <strong>Altitude:</strong><br>
    -                <span class="text-info">${node.altitude}m</span>
    -            </div>
    -            ` : ''}
    -        </div>
    -        ` : node.altitude ? `
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <strong>Altitude:</strong><br>
    -                <span class="text-info">${node.altitude}m</span>
    -            </div>
    -        </div>
    -        ` : ''}
    -        ${node.hw_model ? `
    -        <div class="row mt-2">
    -            <div class="col-12">
    -                <strong>Hardware:</strong><br>
    -                <span class="text-secondary">${node.hw_model}</span>
    -            </div>
    -        </div>
    -        ` : ''}
    -        <div class="row mt-2">
    -            <div class="col-12">
    -                <a href="/node/${node.node_id}" class="btn btn-primary btn-sm">
    -                    <i class="bi bi-router"></i> View Node Details
    -                </a>
    -            </div>
    -        </div>
    -    `;
    +    const detailsButton = buttonLink({
    +        href: safePath(`/node/${encodeURIComponent(String(node.node_id))}`),
    +        className: 'btn btn-primary btn-sm',
    +        iconClass: 'bi bi-router',
    +        text: 'View Node Details'
    +    });
     
    -    document.getElementById('selectedDetailsContent').innerHTML = content;
    +    setChildren(document.getElementById('selectedDetailsContent'),
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-12' },
    +                el('h6', null, node.display_name),
    +                el('p', { className: 'text-muted' }, `Node ID: !${nodeIdHex}`),
    +                node.short_name ? el('p', { className: 'text-muted' }, `Short: ${node.short_name}`) : null
    +            )
    +        ),
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-6' }, el('strong', null, 'Location:'), el('br'), el('small', null, `${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}`)),
    +            el('div', { className: 'col-6' }, el('strong', null, 'Age:'), el('br'), el('span', { className: `age-indicator ${ageClass}` }, formatAge(ageHours)))
    +        ),
    +        node.role || node.altitude ? el('div', { className: 'row mt-2' },
    +            node.role ? el('div', { className: 'col-6' }, el('strong', null, 'Role:'), el('br'), el('span', { className: 'badge', style: { backgroundColor: roleColor } }, node.role)) : null,
    +            node.altitude ? el('div', { className: 'col-6' }, el('strong', null, 'Altitude:'), el('br'), el('span', { className: 'text-info' }, `${node.altitude}m`)) : null
    +        ) : null,
    +        node.hw_model ? el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-12' }, el('strong', null, 'Hardware:'), el('br'), el('span', { className: 'text-secondary' }, node.hw_model))
    +        ) : null,
    +        el('div', { className: 'row mt-2' }, el('div', { className: 'col-12' }, detailsButton))
    +    );
         document.getElementById('selectedDetails').style.display = 'block';
     }
     
    @@ -1164,7 +1156,7 @@ <h6>${node.display_name}</h6>
     
     // Clear search results
     function clearSearchResults() {
    -    document.getElementById('searchResults').innerHTML = '';
    +    document.getElementById('searchResults').replaceChildren();
         searchResults = [];
     }
     
    @@ -1176,28 +1168,17 @@ <h6>${node.display_name}</h6>
         nodeCount.textContent = nodeData.length;
     
         if (nodeData.length === 0) {
    -        container.innerHTML = '<div class="text-center text-muted py-4"><small>No nodes found</small></div>';
    +        setChildren(container,
    +            el('div', { className: 'text-center text-muted py-4' }, el('small', null, 'No nodes found'))
    +        );
             return;
         }
     
         // Sort nodes by name
         const sortedNodes = [...nodeData].sort((a, b) => a.display_name.localeCompare(b.display_name));
     
    -    container.innerHTML = sortedNodes.map(node => {
    -        const nodeIdHex = node.node_id.toString(16).padStart(8, '0');
    -        const ageHours = (Date.now() / 1000 - node.timestamp) / 3600;
    -        const ageClass = getAgeClass(ageHours);
    -        const roleColor = getRoleColor(node.role);
    -
    -        return `
    -            <div class="node-list-item" onclick="selectNodeFromList(${node.node_id})">
    -                <div><strong>${node.display_name}</strong> ${node.role ? `<span class="badge badge-sm" style="background-color: ${roleColor}; font-size: 0.6em;">${formatRole(node.role)}</span>` : ''}</div>
    -                <small class="text-muted">!${nodeIdHex}</small><br>
    -                <span class="age-indicator ${ageClass}">${formatAge(ageHours)}</span>
    -                ${node.hw_model ? `<small class="text-secondary ms-2">${node.hw_model}</small>` : ''}
    -            </div>
    -        `;
    -    }).join('');
    +    container.replaceChildren();
    +    sortedNodes.forEach((node) => container.appendChild(buildNodeListItem(node, selectNodeFromList)));
     }
     
     // Select node from list
    @@ -1212,7 +1193,7 @@ <h6>${node.display_name}</h6>
     // Update node list selection
     function updateNodeListSelection() {
         document.querySelectorAll('.node-list-item').forEach(item => {
    -        const nodeId = parseInt(item.getAttribute('onclick').match(/\d+/)[0]);
    +        const nodeId = parseInt(item.dataset.nodeId || '', 10);
             item.classList.toggle('selected', nodeId === selectedNodeId);
         });
     }
    @@ -1383,12 +1364,12 @@ <h6>${node.display_name}</h6>
         if (showTracerouteLinks) {
             drawTracerouteLinks();
             if (btn) {
    -            btn.innerHTML = '<i class="bi bi-link-45deg"></i> Hide Traceroute Links';
    +            setChildren(btn, icon('bi bi-link-45deg'), textNode(' Hide Traceroute Links'));
                 btn.className = 'btn btn-sm btn-outline-info';
             }
         } else {
             if (btn) {
    -            btn.innerHTML = '<i class="bi bi-link-45deg-off"></i> Show Traceroute Links';
    +            setChildren(btn, icon('bi bi-link-45deg-off'), textNode(' Show Traceroute Links'));
                 btn.className = 'btn btn-sm btn-outline-secondary';
             }
         }
    @@ -1411,12 +1392,12 @@ <h6>${node.display_name}</h6>
         if (showPacketLinks) {
             drawPacketLinks();
             if (btn) {
    -            btn.innerHTML = '<i class="bi bi-envelope-open"></i> Show Packet Links';
    +            setChildren(btn, icon('bi bi-envelope-open'), textNode(' Show Packet Links'));
                 btn.className = 'btn btn-sm btn-outline-secondary';
             }
         } else {
             if (btn) {
    -            btn.innerHTML = '<i class="bi bi-envelope-open"></i> Show Packet Links';
    +            setChildren(btn, icon('bi bi-envelope-open'), textNode(' Show Packet Links'));
                 btn.className = 'btn btn-sm btn-outline-secondary';
             }
         }
    @@ -1598,28 +1579,27 @@ <h6>${node.display_name}</h6>
                              fromNode.latitude && fromNode.longitude &&
                              toNode.latitude && toNode.longitude;
     
    -    return `
    -        <div class="traceroute-link-info">
    -            <div class="fw-bold mb-2">${link.link_type === 'packet' ? 'Direct Packet Link' : 'Traceroute RF Hop'}</div>
    -            <div><strong>From:</strong> ${fromName}</div>
    -            <div><strong>To:</strong> ${toName}</div>
    -            <div><strong>Success Rate:</strong> <span class="${qualityClass}">${link.success_rate.toFixed(1)}%</span></div>
    -            <div><strong>Attempts:</strong> ${link.total_hops_seen}</div>
    -            <div><strong>Last Seen:</strong> ${formattedTime}</div>
    -            ${link.avg_snr ? `<div><strong>Avg SNR:</strong> ${link.avg_snr.toFixed(1)} dB</div>` : ''}
    -            ${link.avg_rssi ? `<div><strong>Avg RSSI:</strong> ${link.avg_rssi.toFixed(0)} dBm</div>` : ''}
    -            <div class="mt-2 d-flex gap-1">
    -                <button class="btn btn-sm btn-primary" onclick="showTracerouteHistory(${link.from_node_id}, ${link.to_node_id})">
    -                    View History
    -                </button>
    -                ${hasLocations ? `
    -                <button class="btn btn-sm btn-success" onclick="showLineOfSight(${link.from_node_id}, ${link.to_node_id})">
    -                    <i class="bi bi-bezier"></i> Line of Sight
    -                </button>
    -                ` : ''}
    -            </div>
    -        </div>
    -    `;
    +    const historyButton = el('button', { className: 'btn btn-sm btn-primary', type: 'button' }, 'View History');
    +    historyButton.addEventListener('click', () => showTracerouteHistory(link.from_node_id, link.to_node_id));
    +
    +    const losButton = hasLocations
    +        ? el('button', { className: 'btn btn-sm btn-success', type: 'button' }, icon('bi bi-bezier'), textNode(' Line of Sight'))
    +        : null;
    +    if (losButton) {
    +        losButton.addEventListener('click', () => showLineOfSight(link.from_node_id, link.to_node_id));
    +    }
    +
    +    return el('div', { className: 'traceroute-link-info' },
    +        el('div', { className: 'fw-bold mb-2' }, link.link_type === 'packet' ? 'Direct Packet Link' : 'Traceroute RF Hop'),
    +        el('div', null, el('strong', null, 'From:'), textNode(` ${fromName}`)),
    +        el('div', null, el('strong', null, 'To:'), textNode(` ${toName}`)),
    +        el('div', null, el('strong', null, 'Success Rate:'), textNode(' '), el('span', { className: qualityClass }, `${link.success_rate.toFixed(1)}%`)),
    +        el('div', null, el('strong', null, 'Attempts:'), textNode(` ${link.total_hops_seen}`)),
    +        el('div', null, el('strong', null, 'Last Seen:'), textNode(` ${formattedTime}`)),
    +        link.avg_snr ? el('div', null, el('strong', null, 'Avg SNR:'), textNode(` ${link.avg_snr.toFixed(1)} dB`)) : null,
    +        link.avg_rssi ? el('div', null, el('strong', null, 'Avg RSSI:'), textNode(` ${link.avg_rssi.toFixed(0)} dBm`)) : null,
    +        el('div', { className: 'mt-2 d-flex gap-1' }, historyButton, losButton)
    +    );
     }
     
     // Show detailed traceroute history between two nodes
    
  • src/malla/templates/node_detail.html+80 32 modified
    @@ -756,6 +756,76 @@ <h5><i class="bi bi-lightning"></i> Quick Actions</h5>
         loadLocationHistoryMap();
     });
     
    +function buildPopupLine(label, value, options = {}) {
    +    const line = el(options.tagName || 'div');
    +    if (label) {
    +        line.appendChild(el('strong', `${label}:`));
    +        line.appendChild(textNode(' '));
    +    }
    +    appendChildren(line, value);
    +    return line;
    +}
    +
    +function buildCurrentLocationPopup(currentLocation) {
    +    const popup = el('div');
    +    appendChildren(
    +        popup,
    +        el('strong', currentLocation.node_name),
    +        el('br'),
    +        el('strong', 'Current Location'),
    +        el('br'),
    +        buildPopupLine('Coordinates', `${currentLocation.latitude.toFixed(6)}, ${currentLocation.longitude.toFixed(6)}`),
    +    );
    +
    +    if (currentLocation.altitude) {
    +        popup.appendChild(buildPopupLine('Altitude', `${currentLocation.altitude}m`));
    +    }
    +
    +    appendChildren(
    +        popup,
    +        buildPopupLine('Updated', currentLocation.timestamp_relative),
    +        el('small', [el('strong', 'Recorded:'), ' ', currentLocation.timestamp])
    +    );
    +
    +    return popup;
    +}
    +
    +function buildLocationHistoryPopup(locationNumber, formattedTime, location) {
    +    const popup = el('div');
    +    appendChildren(
    +        popup,
    +        el('strong', `Location #${locationNumber}`),
    +        el('br'),
    +        el('small', { className: 'text-muted' }, formattedTime),
    +        el('br'),
    +        buildPopupLine('Coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`)
    +    );
    +
    +    if (location.altitude) {
    +        popup.appendChild(buildPopupLine('Altitude', `${location.altitude}m`));
    +    }
    +
    +    return popup;
    +}
    +
    +function showLocationHistoryError(mapContainer, message) {
    +    setChildren(
    +        mapContainer,
    +        el('div', { className: 'd-flex align-items-center justify-content-center h-100' },
    +            el('div', { className: 'text-center text-danger' },
    +                icon('bi bi-exclamation-triangle'),
    +                el('div', { className: 'mt-2' }, 'Error loading location history'),
    +                el('div', { className: 'small' }, message)
    +            )
    +        )
    +    );
    +
    +    const iconEl = mapContainer.querySelector('i');
    +    if (iconEl) {
    +        iconEl.style.fontSize = '2rem';
    +    }
    +}
    +
     function loadCurrentLocationMap() {
         const currentLocationContainer = document.getElementById('current-location-map');
         if (!currentLocationContainer) {
    @@ -792,18 +862,7 @@ <h5><i class="bi bi-lightning"></i> Quick Actions</h5>
         // Add marker for current location
         const marker = L.marker([currentLocation.latitude, currentLocation.longitude]).addTo(currentMap);
     
    -    // Create popup content
    -    const popupContent = `
    -        <div>
    -            <strong>${currentLocation.node_name}</strong><br>
    -            <strong>Current Location</strong><br>
    -            <strong>Coordinates:</strong> ${currentLocation.latitude.toFixed(6)}, ${currentLocation.longitude.toFixed(6)}<br>
    -            ${currentLocation.altitude ? `<strong>Altitude:</strong> ${currentLocation.altitude}m<br>` : ''}
    -            <strong>Updated:</strong> ${currentLocation.timestamp_relative}<br>
    -            <small><strong>Recorded:</strong> ${currentLocation.timestamp}</small>
    -        </div>
    -    `;
    -    marker.bindPopup(popupContent).openPopup();
    +    marker.bindPopup(buildCurrentLocationPopup(currentLocation)).openPopup();
     }
     
     async function loadLocationHistoryMap() {
    @@ -831,7 +890,7 @@ <h5><i class="bi bi-lightning"></i> Quick Actions</h5>
             cardContainer.style.display = 'block';
     
             // Clear loading content
    -        mapContainer.innerHTML = '';
    +        mapContainer.replaceChildren();
     
             // Initialize map centered on the most recent location
             const mostRecentLocation = data.location_history[0];
    @@ -897,15 +956,13 @@ <h5><i class="bi bi-lightning"></i> Quick Actions</h5>
                     ? formatTimestamp(location.timestamp)
                     : location.timestamp_str;
     
    -            const popupContent = `
    -                <div>
    -                    <strong>Location #${data.location_history.length - index}</strong><br>
    -                    <small class="text-muted">${formattedTime}</small><br>
    -                    <strong>Coordinates:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}<br>
    -                    ${location.altitude ? `<strong>Altitude:</strong> ${location.altitude}m<br>` : ''}
    -                </div>
    -            `;
    -            marker.bindPopup(popupContent);
    +            marker.bindPopup(
    +                buildLocationHistoryPopup(
    +                    data.location_history.length - index,
    +                    formattedTime,
    +                    location
    +                )
    +            );
     
                 // Add to path coordinates (in chronological order for path)
                 pathCoords.unshift([location.latitude, location.longitude]);
    @@ -933,16 +990,7 @@ <h5><i class="bi bi-lightning"></i> Quick Actions</h5>
         } catch (error) {
             console.error('Error loading location history:', error);
     
    -        // Show error in map container
    -        mapContainer.innerHTML = `
    -            <div class="d-flex align-items-center justify-content-center h-100">
    -                <div class="text-center text-danger">
    -                    <i class="bi bi-exclamation-triangle" style="font-size: 2rem;"></i>
    -                    <div class="mt-2">Error loading location history</div>
    -                    <div class="small">${error.message}</div>
    -                </div>
    -            </div>
    -        `;
    +        showLocationHistoryError(mapContainer, error.message);
     
             // Still show the card even on error so users can see what went wrong
             cardContainer.style.display = 'block';
    
  • src/malla/templates/nodes.html+51 46 modified
    @@ -254,31 +254,31 @@ <h6><i class="bi bi-palette"></i> Legend</h6>
                     title: 'Node ID',
                     sortable: true,
                     render: (value, row) => {
    -                    return `<a href="/node/${row.node_id}" class="text-decoration-none font-monospace" title="View node details">
    -                                <small>${value}</small>
    -                            </a>`;
    +                    return el('a', {
    +                        href: safePath(`/node/${encodeURIComponent(String(row.node_id))}`),
    +                        className: 'text-decoration-none font-monospace',
    +                        title: 'View node details'
    +                    }, el('small', null, value || ''));
                     }
                 },
                 {
                     key: 'node_name',
                     title: 'Name',
                     sortable: true,
                     render: (value, row) => {
    -                    const displayName = value || 'Unnamed';
    -                    return `<a href="/node/${row.node_id}" class="text-decoration-none" title="View node details">
    -                                ${displayName}
    -                            </a>`;
    +                    return el('a', {
    +                        href: safePath(`/node/${encodeURIComponent(String(row.node_id))}`),
    +                        className: 'text-decoration-none',
    +                        title: 'View node details'
    +                    }, value || 'Unnamed');
                     }
                 },
                 {
                     key: 'hw_model',
                     title: 'Hardware',
                     sortable: true,
                     render: (value, row) => {
    -                    if (value && value !== 'Unknown') {
    -                        return `<span class="badge bg-info">${value}</span>`;
    -                    }
    -                    return `<span class="badge bg-light text-dark">Unknown</span>`;
    +                    return badge(value && value !== 'Unknown' ? value : 'Unknown', value && value !== 'Unknown' ? 'bg-info' : 'bg-light text-dark');
                     }
                 },
                 {
    @@ -287,19 +287,19 @@ <h6><i class="bi bi-palette"></i> Legend</h6>
                     sortable: true,
                     render: (value, row) => {
                         if (value && value !== 'Unknown') {
    -                        let badgeClass = 'bg-secondary';
    -                        if (value === 'CLIENT') badgeClass = 'bg-primary';
    -                        else if (value === 'ROUTER') badgeClass = 'bg-success';
    -                        else if (value === 'ROUTER_LATE') badgeClass = 'bg-success';
    -                        else if (value === 'REPEATER') badgeClass = 'bg-warning';
    -                        else if (value === 'CLIENT_MUTE') badgeClass = 'bg-secondary';
    -                        else if (value === 'CLIENT_BASE') badgeClass = 'bg-secondary';
    -                        else if (value === 'ROUTER_CLIENT') badgeClass = 'bg-info';
    -                        else if (value === 'SENSOR') badgeClass = 'bg-dark';
    -
    -                        return `<span class="badge ${badgeClass}">${value}</span>`;
    +                        const roleColors = {
    +                            CLIENT: 'bg-primary',
    +                            ROUTER: 'bg-success',
    +                            ROUTER_LATE: 'bg-success',
    +                            REPEATER: 'bg-warning',
    +                            CLIENT_MUTE: 'bg-secondary',
    +                            CLIENT_BASE: 'bg-secondary',
    +                            ROUTER_CLIENT: 'bg-info',
    +                            SENSOR: 'bg-dark'
    +                        };
    +                        return badge(value, roleColors[value] || 'bg-secondary');
                         }
    -                    return `<span class="badge bg-light text-dark">Unknown</span>`;
    +                    return badge('Unknown', 'bg-light text-dark');
                     }
                 },
                 {
    @@ -309,12 +309,13 @@ <h6><i class="bi bi-palette"></i> Legend</h6>
                     sortKey: 'last_packet_time',
                     render: (value, row) => {
                         if (value && value !== 'Never') {
    -                        // Use last_packet_time (unix timestamp) if available, otherwise use value (pre-formatted string)
                             const timestamp = row.last_packet_time || value;
    -                        const formatted = formatTimestamp(timestamp);
    -                        return `<small class="timestamp-display" data-timestamp="${timestamp}">${formatted}</small>`;
    +                        return el('small', {
    +                            className: 'timestamp-display',
    +                            dataset: { timestamp }
    +                        }, formatTimestamp(timestamp));
                         }
    -                    return `<span class="text-muted">Never</span>`;
    +                    return el('span', { className: 'text-muted' }, 'Never');
                     }
                 },
                 {
    @@ -328,47 +329,51 @@ <h6><i class="bi bi-palette"></i> Legend</h6>
                             if (count < 10) badgeClass = 'bg-warning';
                             else if (count < 50) badgeClass = 'bg-info';
     
    -                        return `<span class="badge ${badgeClass}">${count}</span>`;
    +                        return badge(String(count), badgeClass);
                         }
    -                    return `<span class="badge bg-light text-dark">0</span>`;
    +                    return badge('0', 'bg-light text-dark');
                     }
                 },
                 {
                     key: 'primary_channel',
                     title: 'Channel',
                     sortable: true,
                     render: (value) => {
    -                    return value ? `<span class="badge bg-secondary">${value}</span>` : '<span class="text-muted">Unknown</span>';
    +                    return value ? badge(value, 'bg-secondary') : el('span', { className: 'text-muted' }, 'Unknown');
                     }
                 },
                 {
                     key: 'node_id',
                     title: 'Actions',
                     sortable: false,
                     render: (value, row) => {
    -                    // Create filtered URLs using the URL manager
                         const packetsUrl = urlManager.createFilteredURL('/packets', {
                             from_node: value
                         });
                         const tracerouteUrl = urlManager.createFilteredURL('/traceroute', {
                             from_node: value
                         });
     
    -                    return `
    -                        <div class="btn-group" role="group">
    -                            <a href="/node/${value}"
    -                               class="btn btn-sm btn-outline-primary" title="View node details">
    -                                <i class="bi bi-info-circle"></i>
    -                            </a>
    -                            <a href="${packetsUrl}"
    -                               class="btn btn-sm btn-outline-secondary" title="View packets from this node">
    -                                <i class="bi bi-envelope"></i>
    -                            </a>
    -                            <a href="${tracerouteUrl}"
    -                               class="btn btn-sm btn-outline-info" title="View traceroutes from this node">
    -                                <i class="bi bi-diagram-3"></i>
    -                            </a>
    -                        </div>`;
    +                    return el('div', { className: 'btn-group', role: 'group' },
    +                        buttonLink({
    +                            href: safePath(`/node/${encodeURIComponent(String(value))}`),
    +                            className: 'btn btn-sm btn-outline-primary',
    +                            title: 'View node details',
    +                            iconClass: 'bi bi-info-circle'
    +                        }),
    +                        buttonLink({
    +                            href: packetsUrl,
    +                            className: 'btn btn-sm btn-outline-secondary',
    +                            title: 'View packets from this node',
    +                            iconClass: 'bi bi-envelope'
    +                        }),
    +                        buttonLink({
    +                            href: tracerouteUrl,
    +                            className: 'btn btn-sm btn-outline-info',
    +                            title: 'View traceroutes from this node',
    +                            iconClass: 'bi bi-diagram-3'
    +                        })
    +                    );
                     }
                 }
             ]
    
  • src/malla/templates/packet_detail.html+124 73 modified
    @@ -934,6 +934,101 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
         }
     });
     
    +function popupBadge(text, className, style = null) {
    +    return el('span', {
    +        className: className ? `badge ${className}` : 'badge',
    +        style: style || null
    +    }, text);
    +}
    +
    +function popupSmall(text) {
    +    return el('small', text);
    +}
    +
    +function buildRouteLocationPopup(location, index, totalLocations) {
    +    const popup = el('div');
    +    const isStart = index === 0;
    +    const isEnd = index === totalLocations - 1;
    +
    +    appendChildren(popup, el('strong', location.display_name || location.original_id), el('br'));
    +
    +    if (isStart) {
    +        appendChildren(popup, popupBadge('Start', 'bg-success'), ' ');
    +    }
    +    if (isEnd) {
    +        appendChildren(popup, popupBadge('End', 'bg-danger'), ' ');
    +    }
    +    if (!isStart && !isEnd) {
    +        appendChildren(popup, popupBadge(`Hop ${index}`, 'bg-primary'), ' ');
    +    }
    +    if (location.interpolated) {
    +        appendChildren(popup, popupBadge('Interpolated', 'bg-warning'));
    +    }
    +
    +    appendChildren(
    +        popup,
    +        el('br'),
    +        popupSmall(location.interpolated ? 'Estimated location' : `Last seen: ${location.timestamp_str}`)
    +    );
    +
    +    return popup;
    +}
    +
    +function buildRelayCandidatePopup(title, badgeText, details, badgeStyle = null) {
    +    const popup = el('div', el('strong', title), el('br'), popupBadge(badgeText, '', badgeStyle));
    +    details.forEach((detail) => {
    +        appendChildren(popup, el('br'), popupSmall(detail));
    +    });
    +    return popup;
    +}
    +
    +function buildGatewayPopup(gateway) {
    +    const popup = el('div');
    +    appendChildren(
    +        popup,
    +        el('strong', gateway.display_name || gateway.gateway_id),
    +        el('br'),
    +        popupBadge(gateway.is_primary ? 'Primary Gateway' : 'Reception', gateway.is_primary ? 'bg-success' : 'bg-info'),
    +        el('br'),
    +        popupSmall(`Location: ${gateway.timestamp_str}`)
    +    );
    +
    +    if (!gateway.is_primary) {
    +        appendChildren(popup, el('br'), popupSmall(`Time offset: ${gateway.time_diff > 0 ? '+' : ''}${gateway.time_diff.toFixed(3)}s`));
    +    }
    +
    +    if (gateway.hop_count !== null && gateway.hop_count !== undefined) {
    +        appendChildren(popup, el('br'), popupSmall(`Hops: ${gateway.hop_count}`));
    +    }
    +
    +    if (gateway.rssi) {
    +        appendChildren(popup, el('br'), popupSmall(`RSSI: ${gateway.rssi} dBm`));
    +    }
    +
    +    if (gateway.snr) {
    +        appendChildren(popup, el('br'), popupSmall(`SNR: ${gateway.snr} dB`));
    +    }
    +
    +    return popup;
    +}
    +
    +function buildMapError(mapContainer, message) {
    +    setChildren(
    +        mapContainer,
    +        el('div', { className: 'd-flex align-items-center justify-content-center h-100' },
    +            el('div', { className: 'text-center text-muted' },
    +                icon('bi bi-exclamation-triangle'),
    +                el('p', { className: 'mt-2' }, message)
    +            )
    +        )
    +    );
    +
    +    const iconEl = mapContainer.querySelector('i');
    +    if (iconEl) {
    +        iconEl.style.fontSize = '2rem';
    +    }
    +}
    +
     async function loadTracerouteMap(routeNodes) {
         try {
             // Convert node IDs to integers for API call, handling both integers and hex strings
    @@ -1123,18 +1218,7 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
                     fillOpacity: 0.8
                 }).addTo(map);
     
    -            // Add popup
    -            const popupContent = `
    -                <div>
    -                    <strong>${location.display_name || location.original_id}</strong><br>
    -                    ${isStart ? '<span class="badge bg-success">Start</span>' : ''}
    -                    ${isEnd ? '<span class="badge bg-danger">End</span>' : ''}
    -                    ${!isStart && !isEnd ? '<span class="badge bg-primary">Hop ' + index + '</span>' : ''}
    -                    ${location.interpolated ? '<span class="badge bg-warning">Interpolated</span>' : ''}
    -                    <br><small>${location.interpolated ? 'Estimated location' : 'Last seen: ' + location.timestamp_str}</small>
    -                </div>
    -            `;
    -            marker.bindPopup(popupContent);
    +            marker.bindPopup(buildRouteLocationPopup(location, index, routeLocations.length));
     
                 routeCoords.push([location.latitude, location.longitude]);
             });
    @@ -1182,14 +1266,12 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
                         fillOpacity: 0.8
                     }).addTo(map);
     
    -                // Add popup for relay candidate
    -                relayMarker.bindPopup(`
    -                    <div>
    -                        <strong>${relayCandidateLocation.display_name || relayCandidate.hex_id}</strong><br>
    -                        <span class="badge" style="background-color: #ff6b35;">Relay Candidate (0x${window.packetData.relayHex})</span>
    -                        <br><small>Last hop that likely relayed this packet</small>
    -                    </div>
    -                `);
    +                relayMarker.bindPopup(buildRelayCandidatePopup(
    +                    relayCandidateLocation.display_name || relayCandidate.hex_id,
    +                    `Relay Candidate (0x${window.packetData.relayHex})`,
    +                    ['Last hop that likely relayed this packet'],
    +                    { backgroundColor: '#ff6b35', color: '#fff' }
    +                ));
     
                     // Update bounds to include relay candidate
                     routeCoords.push([relayCandidateLocation.latitude, relayCandidateLocation.longitude]);
    @@ -1324,33 +1406,7 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
                     fillOpacity: 0.8
                 }).addTo(map);
     
    -            // Create popup content
    -            let popupContent = `
    -                <div>
    -                    <strong>${gateway.display_name || gateway.gateway_id}</strong><br>
    -                    ${gateway.is_primary ? '<span class="badge bg-success">Primary Gateway</span>' : '<span class="badge bg-info">Reception</span>'}
    -                    <br><small>Location: ${gateway.timestamp_str}</small>
    -            `;
    -
    -            if (!gateway.is_primary) {
    -                popupContent += `<br><small>Time offset: ${gateway.time_diff > 0 ? '+' : ''}${gateway.time_diff.toFixed(3)}s</small>`;
    -            }
    -
    -            if (gateway.hop_count !== null && gateway.hop_count !== undefined) {
    -                popupContent += `<br><small>Hops: ${gateway.hop_count}</small>`;
    -            }
    -
    -            if (gateway.rssi) {
    -                popupContent += `<br><small>RSSI: ${gateway.rssi} dBm</small>`;
    -            }
    -
    -            if (gateway.snr) {
    -                popupContent += `<br><small>SNR: ${gateway.snr} dB</small>`;
    -            }
    -
    -            popupContent += '</div>';
    -
    -            marker.bindPopup(popupContent);
    +            marker.bindPopup(buildGatewayPopup(gateway));
     
                 // Add hop count label if available
                 if (gateway.hop_count !== null && gateway.hop_count !== undefined) {
    @@ -1397,15 +1453,16 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
                     }).addTo(map);
     
                     // Add popup for relay candidate
    -                relayMarker.bindPopup(`
    -                    <div>
    -                        <strong>${relay.display_name || relay.node_name}</strong><br>
    -                        <span class="badge" style="background-color: #ff6b35;">Relay Candidate</span>
    -                        <br><small>Node: ${relay.short_name} (0x${window.packetData.relayHex})</small>
    -                        <br><small>Last hop that likely relayed this packet</small>
    -                        <br><small>Location: ${relay.timestamp_str}</small>
    -                    </div>
    -                `);
    +                relayMarker.bindPopup(buildRelayCandidatePopup(
    +                    relay.display_name || relay.node_name,
    +                    'Relay Candidate',
    +                    [
    +                        `Node: ${relay.short_name} (0x${window.packetData.relayHex})`,
    +                        'Last hop that likely relayed this packet',
    +                        `Location: ${relay.timestamp_str}`
    +                    ],
    +                    { backgroundColor: '#ff6b35', color: '#fff' }
    +                ));
     
                     // Add relay location to coords for bounds
                     coords.push([relay.latitude, relay.longitude]);
    @@ -1447,15 +1504,16 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
                                 }).addTo(map);
     
                                 // Add popup for relay candidate
    -                            relayMarker.bindPopup(`
    -                                <div>
    -                                    <strong>${location.display_name || candidate.node_name}</strong><br>
    -                                    <span class="badge" style="background-color: #ff6b35;">Relay Candidate</span>
    -                                    <br><small>Node: ${candidate.short_name} (0x${reception.relay_hex})</small>
    -                                    <br><small>Relayed to: ${gatewayLocation.display_name}</small>
    -                                    <br><small>Location: ${location.timestamp_str}</small>
    -                                </div>
    -                            `);
    +                            relayMarker.bindPopup(buildRelayCandidatePopup(
    +                                location.display_name || candidate.node_name,
    +                                'Relay Candidate',
    +                                [
    +                                    `Node: ${candidate.short_name} (0x${reception.relay_hex})`,
    +                                    `Relayed to: ${gatewayLocation.display_name}`,
    +                                    `Location: ${location.timestamp_str}`
    +                                ],
    +                                { backgroundColor: '#ff6b35', color: '#fff' }
    +                            ));
     
                                 // Add relay location to coords for bounds
                                 coords.push([location.latitude, location.longitude]);
    @@ -1478,14 +1536,7 @@ <h6 class="card-title mb-1">Total Return Distance</h6>
     function showMapError(mapId, message) {
         const mapContainer = document.getElementById(mapId);
         if (mapContainer) {
    -        mapContainer.innerHTML = `
    -            <div class="d-flex align-items-center justify-content-center h-100">
    -                <div class="text-center text-muted">
    -                    <i class="bi bi-exclamation-triangle" style="font-size: 2rem;"></i>
    -                    <p class="mt-2">${message}</p>
    -                </div>
    -            </div>
    -        `;
    +        buildMapError(mapContainer, message);
         }
     }
     
    
  • src/malla/templates/packets.html+44 68 modified
    @@ -268,14 +268,9 @@
                     render: (value, row) => {
                         if (row.from_node_id) {
                             const shortName = row.from_node_short || row.from_node_id.toString(16).padStart(8, '0').slice(-4);
    -                        return `<a href="/node/${row.from_node_id}" class="text-decoration-none node-link"
    -                                   data-node-id="${row.from_node_id}" data-bs-toggle="tooltip"
    -                                   data-bs-placement="top" data-bs-html="true"
    -                                   data-bs-title="Loading..." title="View node details">
    -                                    ${shortName}
    -                                </a>`;
    +                        return nodeLink(row.from_node_id, shortName);
                         }
    -                    return `<span class="text-muted">Unknown</span>`;
    +                    return el('span', { className: 'text-muted' }, 'Unknown');
                     }
                 },
                 {
    @@ -285,14 +280,9 @@
                     render: (value, row) => {
                         if (row.to_node_id) {
                             const shortName = row.to_node_short || row.to_node_id.toString(16).padStart(8, '0').slice(-4);
    -                        return `<a href="/node/${row.to_node_id}" class="text-decoration-none node-link"
    -                                   data-node-id="${row.to_node_id}" data-bs-toggle="tooltip"
    -                                   data-bs-placement="top" data-bs-html="true"
    -                                   data-bs-title="Loading..." title="View node details">
    -                                    ${shortName}
    -                                </a>`;
    +                        return nodeLink(row.to_node_id, shortName);
                         }
    -                    return `<span class="text-muted">Broadcast</span>`;
    +                    return el('span', { className: 'text-muted' }, 'Broadcast');
                     }
                 },
                 {
    @@ -312,9 +302,9 @@
                                 'NEIGHBORINFO_APP': 'Neighbor Info'
                             };
                             const displayName = typeMap[value] || value;
    -                        return `<span class="badge bg-secondary">${displayName}</span>`;
    +                        return badge(displayName, 'bg-secondary');
                         }
    -                    return `<span class="text-muted">Unknown</span>`;
    +                    return el('span', { className: 'text-muted' }, 'Unknown');
                     }
                 }
             ],
    @@ -327,13 +317,9 @@
                     sortable: true,
                     render: (value, row) => {
                         if (value && value !== 'Unknown') {
    -                        if (value === 'LongFast') {
    -                            return `<span class="badge bg-primary">${value}</span>`;
    -                        } else {
    -                            return `<span class="badge bg-info">${value}</span>`;
    -                        }
    +                        return badge(value, value === 'LongFast' ? 'bg-primary' : 'bg-info');
                         }
    -                    return `<span class="text-muted">Unknown</span>`;
    +                    return el('span', { className: 'text-muted' }, 'Unknown');
                     }
                 }
             ],
    @@ -347,9 +333,9 @@
                         sortable: false,
                         render: (value, row) => {
                             if (value) {
    -                            return `<span class="text-break">${value}</span>`;
    +                            return el('span', { className: 'text-break' }, value);
                             }
    -                        return `<span class="text-muted">No content</span>`;
    +                        return el('span', { className: 'text-muted' }, 'No content');
                         }
                     }
                 ]
    @@ -365,35 +351,25 @@
                         if (row.is_grouped) {
                             const count = row.gateway_count;
                             if (count > 1) {
    -                            return `<span class="badge bg-info" title="Multiple gateways: ${row.gateway_list}">
    -                                        ${count} gateways
    -                                    </span>`;
    -                        } else {
    -                            return `<span class="badge bg-info">${value}</span>`;
    +                            return el('span', {
    +                                className: 'badge bg-info',
    +                                title: `Multiple gateways: ${row.gateway_list}`
    +                            }, `${count} gateways`);
                             }
    +                        return badge(value, 'bg-info');
                         } else {
                             const gatewayName = row.gateway_name;
                             const gatewayNodeId = row.gateway_node_id;
     
                             if (value && value.startsWith('!') && gatewayNodeId) {
                                 const shortName = value.substring(value.length - 4).toUpperCase();
    -                            return `<a href="/node/${gatewayNodeId}" class="text-decoration-none node-link"
    -                                       data-node-id="${gatewayNodeId}" data-bs-toggle="tooltip"
    -                                       data-bs-placement="top" data-bs-html="true"
    -                                       data-bs-title="Loading..." title="View node details">
    -                                        ${shortName}
    -                                    </a>`;
    +                            return nodeLink(gatewayNodeId, shortName);
                             } else if (gatewayName && gatewayNodeId) {
                                 const parenMatch = gatewayName.match(/\(([^)]+)\)$/);
                                 const shortName = parenMatch ? parenMatch[1] : gatewayName.substring(0, 4).toUpperCase();
    -                            return `<a href="/node/${gatewayNodeId}" class="text-decoration-none node-link"
    -                                       data-node-id="${gatewayNodeId}" data-bs-toggle="tooltip"
    -                                       data-bs-placement="top" data-bs-html="true"
    -                                       data-bs-title="Loading..." title="View node details">
    -                                        ${shortName}
    -                                    </a>`;
    +                            return nodeLink(gatewayNodeId, shortName);
                             }
    -                        return value || 'N/A';
    +                        return textNode(value || 'N/A');
                         }
                     }
                 },
    @@ -403,16 +379,16 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span>${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', null, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '' && value !== 'N/A') {
                             const rssiValue = parseFloat(value);
                             const colorClass = getRssiColorClass(rssiValue);
                             const formattedValue = rssiValue.toFixed(1);
    -                        return `<span class="${colorClass}">${formattedValue} dBm</span>`;
    +                        return el('span', { className: colorClass }, `${formattedValue} dBm`);
                         }
    -                    return '<span class="text-muted">N/A</span>';
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
    @@ -421,16 +397,16 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span>${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', null, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '' && value !== 'N/A') {
                             const snrValue = parseFloat(value);
                             const colorClass = getSnrColorClass(snrValue);
                             const formattedValue = snrValue.toFixed(2);
    -                        return `<span class="${colorClass}">${formattedValue} dB</span>`;
    +                        return el('span', { className: colorClass }, `${formattedValue} dB`);
                         }
    -                    return '<span class="text-muted">N/A</span>';
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
    @@ -439,38 +415,35 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span class="text-info">${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', { className: 'text-info' }, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '') {
                             const hops = parseInt(value);
                             if (hops === 0) {
    -                            return `<span class="badge bg-success">Direct</span>`;
    +                            return badge('Direct', 'bg-success');
                             } else if (hops <= 2) {
    -                            return `<span class="badge bg-info">${hops}</span>`;
    +                            return badge(String(hops), 'bg-info');
                             } else {
    -                            return `<span class="badge bg-warning">${hops}</span>`;
    +                            return badge(String(hops), 'bg-warning');
                             }
                         }
    -                    return `<span class="text-muted">N/A</span>`;
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
                     key: 'relay_node',
                     title: 'Relay Node',
                     sortable: true,
                     render: (value, row) => {
    -                    // For grouped packets, value is already a formatted string (e.g., "0a, 3f*2")
                         if (row.is_grouped && typeof value === 'string' && value) {
    -                        return `<code>${value}</code>`;
    +                        return el('code', null, value);
                         }
    -                    // For individual packets, value is an integer
                         if (value !== null && value !== undefined && value !== 0 && typeof value === 'number') {
    -                        // Extract last byte
                             const relayHex = (value & 0xFF).toString(16).padStart(2, '0');
    -                        return `<code>${relayHex}</code>`;
    +                        return el('code', null, relayHex);
                         }
    -                    return `<span class="text-muted">-</span>`;
    +                    return el('span', { className: 'text-muted' }, '-');
                     }
                 },
               ],
    @@ -482,9 +455,12 @@
                     title: 'Actions',
                     sortable: false,
                     render: (value, row) => {
    -                    return `<a href="/packet/${value}" class="btn btn-sm btn-outline-primary" title="View packet details">
    -                                <i class="bi bi-eye"></i>
    -                            </a>`;
    +                    return buttonLink({
    +                        href: safePath(`/packet/${encodeURIComponent(String(value))}`),
    +                        className: 'btn btn-sm btn-outline-primary',
    +                        title: 'View packet details',
    +                        iconClass: 'bi bi-eye'
    +                    });
                     }
                 }
             ]
    
  • src/malla/templates/traceroute_graph.html+190 197 modified
    @@ -437,9 +437,8 @@ <h6><i class="bi bi-bar-chart"></i> Statistics</h6>
     
             if (!data.nodes || data.nodes.length === 0) {
                 console.log('No nodes found in data');
    -            container.append('div')
    -                .attr('class', 'loading-overlay')
    -                .html('<i class="bi bi-diagram-3"></i><br>No network data found for the selected criteria');
    +            const empty = el('div', { className: 'loading-overlay' }, icon('bi bi-diagram-3'), el('br'), textNode('No network data found for the selected criteria'));
    +            container.node().appendChild(empty);
                 return;
             }
     
    @@ -825,19 +824,20 @@ <h6><i class="bi bi-bar-chart"></i> Statistics</h6>
         const container = document.getElementById('searchResults');
     
         if (results.length === 0) {
    -        container.innerHTML = '<small class="text-muted">No nodes found</small>';
    +        setChildren(container, el('small', { className: 'text-muted' }, 'No nodes found'));
             return;
         }
     
    -    container.innerHTML = results.map((node, index) => {
    +    container.replaceChildren();
    +    results.forEach((node, index) => {
             const nodeIdHex = node.id.toString(16).padStart(8, '0');
    -        return `
    -            <div class="search-result-item" onclick="selectSearchResult(${index})">
    -                <div><strong>${node.name}</strong></div>
    -                <small class="text-muted">!${nodeIdHex} • ${node.connections} connections</small>
    -            </div>
    -        `;
    -    }).join('');
    +        const item = el('div', { className: 'search-result-item' },
    +            el('div', null, el('strong', null, node.name)),
    +            el('small', { className: 'text-muted' }, `!${nodeIdHex} • ${node.connections} connections`)
    +        );
    +        item.addEventListener('click', () => selectSearchResult(index));
    +        container.appendChild(item);
    +    });
     }
     
     function selectSearchResult(index) {
    @@ -1121,54 +1121,60 @@ <h6><i class="bi bi-bar-chart"></i> Statistics</h6>
     }
     
     function clearSearchResults() {
    -    document.getElementById('searchResults').innerHTML = '';
    +    document.getElementById('searchResults').replaceChildren();
         searchResults = [];
     }
     
    +function setSelectedDetails(title, ...content) {
    +    document.getElementById('selectedDetailsTitle').textContent = title;
    +    setChildren(document.getElementById('selectedDetailsContent'), ...content);
    +    document.getElementById('selectedDetails').style.display = 'block';
    +}
    +
    +function setHoverDetails(...content) {
    +    setChildren(document.getElementById('hoverDetails'), ...content);
    +}
    +
     function showSelectedNodeDetails(node) {
         const nodeIdHex = node.id.toString(16).padStart(8, '0');
    -    const content = `
    -        <div class="row">
    -            <div class="col-12">
    -                <h6>${node.name}</h6>
    -                <p class="text-muted">Node ID: !${nodeIdHex}</p>
    -                <div class="alert alert-info py-2 px-2 small mb-2">
    -                    <i class="bi bi-lightbulb"></i> Click another node to show paths between them
    -                </div>
    -            </div>
    -        </div>
    -        <div class="row">
    -            <div class="col-6">
    -                <strong>Connections:</strong><br>
    -                <span class="h5 text-primary">${node.connections}</span>
    -            </div>
    -            <div class="col-6">
    -                <strong>Packets:</strong><br>
    -                <span class="h5 text-info">${node.packet_count}</span>
    -            </div>
    -        </div>
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <strong>Avg SNR:</strong><br>
    -                <span class="h6 ${node.avg_snr ? (node.avg_snr > -10 ? 'text-success' : 'text-warning') : 'text-muted'}">${node.avg_snr ? node.avg_snr + ' dB' : 'N/A'}</span>
    -            </div>
    -            <div class="col-6">
    -                <strong>Last Seen:</strong><br>
    -                <small class="text-muted">${new Date(node.last_seen * 1000).toLocaleString()}</small>
    -            </div>
    -        </div>
    -        <div class="row mt-2">
    -            <div class="col-12">
    -                <a href="/node/${node.id}" class="btn btn-primary btn-sm w-100">
    -                    <i class="bi bi-router"></i> View Node Details
    -                </a>
    -            </div>
    -        </div>
    -    `;
    -
    -    document.getElementById('selectedDetailsTitle').textContent = 'Selected Node';
    -    document.getElementById('selectedDetailsContent').innerHTML = content;
    -    document.getElementById('selectedDetails').style.display = 'block';
    +    setSelectedDetails('Selected Node',
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-12' },
    +                el('h6', null, node.name),
    +                el('p', { className: 'text-muted' }, `Node ID: !${nodeIdHex}`),
    +                el('div', { className: 'alert alert-info py-2 px-2 small mb-2' },
    +                    icon('bi bi-lightbulb'),
    +                    textNode(' Click another node to show paths between them')
    +                )
    +            )
    +        ),
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-6' }, el('strong', null, 'Connections:'), el('br'), el('span', { className: 'h5 text-primary' }, String(node.connections))),
    +            el('div', { className: 'col-6' }, el('strong', null, 'Packets:'), el('br'), el('span', { className: 'h5 text-info' }, String(node.packet_count)))
    +        ),
    +        el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-6' },
    +                el('strong', null, 'Avg SNR:'),
    +                el('br'),
    +                el('span', { className: `h6 ${node.avg_snr ? (node.avg_snr > -10 ? 'text-success' : 'text-warning') : 'text-muted'}` }, node.avg_snr ? `${node.avg_snr} dB` : 'N/A')
    +            ),
    +            el('div', { className: 'col-6' },
    +                el('strong', null, 'Last Seen:'),
    +                el('br'),
    +                el('small', { className: 'text-muted' }, new Date(node.last_seen * 1000).toLocaleString())
    +            )
    +        ),
    +        el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-12' },
    +                buttonLink({
    +                    href: safePath(`/node/${encodeURIComponent(String(node.id))}`),
    +                    className: 'btn btn-primary btn-sm w-100',
    +                    iconClass: 'bi bi-router',
    +                    text: 'View Node Details'
    +                })
    +            )
    +        )
    +    );
     }
     
     function showPathComparisonDetails() {
    @@ -1180,67 +1186,64 @@ <h6>${node.name}</h6>
         const node1IdHex = selectedNode.id.toString(16).padStart(8, '0');
         const node2IdHex = comparisonNode.id.toString(16).padStart(8, '0');
     
    -    let pathsHtml = '';
    +    let pathsBlock;
         if (pathsData.paths.length === 0) {
    -        pathsHtml = '<div class="alert alert-warning py-2 px-2 small">No path found between these nodes</div>';
    +        pathsBlock = el('div', { className: 'alert alert-warning py-2 px-2 small' }, 'No path found between these nodes');
         } else {
    -        pathsHtml = `
    -            <div class="mb-2">
    -                <strong>${pathsData.paths.length} path${pathsData.paths.length > 1 ? 's' : ''} found</strong>
    -                <small class="text-muted"> (${pathsData.distance} hop${pathsData.distance > 1 ? 's' : ''})</small>
    -            </div>
    -            <div class="small" style="max-height: 200px; overflow-y: auto;">
    -        `;
    -
    +        const pathList = el('div', { className: 'small', style: { maxHeight: '200px', overflowY: 'auto' } });
             pathsData.paths.forEach((path, idx) => {
    -            const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' → ');
    -            pathsHtml += `<div class="mb-2 p-2 bg-light rounded"><strong>${idx + 1}.</strong> ${pathNames}</div>`;
    +            const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' -> '.replace('->', '→'));
    +            pathList.appendChild(el('div', { className: 'mb-2 p-2 bg-light rounded' }, el('strong', null, `${idx + 1}.`), textNode(` ${pathNames}`)));
             });
    -
    -        pathsHtml += '</div>';
    +        pathsBlock = fragment(
    +            el('div', { className: 'mb-2' },
    +                el('strong', null, `${pathsData.paths.length} path${pathsData.paths.length > 1 ? 's' : ''} found`),
    +                textNode(` `),
    +                el('small', { className: 'text-muted' }, `(${pathsData.distance} hop${pathsData.distance > 1 ? 's' : ''})`)
    +            ),
    +            pathList
    +        );
         }
     
    -    const content = `
    -        <div class="mb-3">
    -            <div class="d-flex align-items-center mb-2">
    -                <span class="badge bg-primary me-2">1</span>
    -                <div>
    -                    <strong>${selectedNode.name}</strong><br>
    -                    <small class="text-muted">!${node1IdHex}</small>
    -                </div>
    -            </div>
    -            <div class="d-flex align-items-center">
    -                <span class="badge bg-success me-2">2</span>
    -                <div>
    -                    <strong>${comparisonNode.name}</strong><br>
    -                    <small class="text-muted">!${node2IdHex}</small>
    -                </div>
    -            </div>
    -        </div>
    -
    -        ${pathsHtml}
    -
    -        <div class="alert alert-info py-2 px-2 small mt-2 mb-2">
    -            <i class="bi bi-lightbulb"></i> Click a node again to deselect it, or click elsewhere to select different nodes
    -        </div>
    -
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <a href="/node/${selectedNode.id}" class="btn btn-outline-primary btn-sm w-100" target="_blank">
    -                    <i class="bi bi-router"></i> Node 1
    -                </a>
    -            </div>
    -            <div class="col-6">
    -                <a href="/node/${comparisonNode.id}" class="btn btn-outline-success btn-sm w-100" target="_blank">
    -                    <i class="bi bi-router"></i> Node 2
    -                </a>
    -            </div>
    -        </div>
    -    `;
    -
    -    document.getElementById('selectedDetailsTitle').textContent = 'Path Comparison';
    -    document.getElementById('selectedDetailsContent').innerHTML = content;
    -    document.getElementById('selectedDetails').style.display = 'block';
    +    setSelectedDetails('Path Comparison',
    +        el('div', { className: 'mb-3' },
    +            el('div', { className: 'd-flex align-items-center mb-2' },
    +                badge('1', 'bg-primary me-2'),
    +                el('div', null, el('strong', null, selectedNode.name), el('br'), el('small', { className: 'text-muted' }, `!${node1IdHex}`))
    +            ),
    +            el('div', { className: 'd-flex align-items-center' },
    +                badge('2', 'bg-success me-2'),
    +                el('div', null, el('strong', null, comparisonNode.name), el('br'), el('small', { className: 'text-muted' }, `!${node2IdHex}`))
    +            )
    +        ),
    +        pathsBlock,
    +        el('div', { className: 'alert alert-info py-2 px-2 small mt-2 mb-2' },
    +            icon('bi bi-lightbulb'),
    +            textNode(' Click a node again to deselect it, or click elsewhere to select different nodes')
    +        ),
    +        el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-6' },
    +                buttonLink({
    +                    href: safePath(`/node/${encodeURIComponent(String(selectedNode.id))}`),
    +                    className: 'btn btn-outline-primary btn-sm w-100',
    +                    iconClass: 'bi bi-router',
    +                    text: 'Node 1',
    +                    target: '_blank',
    +                    rel: 'noopener noreferrer'
    +                })
    +            ),
    +            el('div', { className: 'col-6' },
    +                buttonLink({
    +                    href: safePath(`/node/${encodeURIComponent(String(comparisonNode.id))}`),
    +                    className: 'btn btn-outline-success btn-sm w-100',
    +                    iconClass: 'bi bi-router',
    +                    text: 'Node 2',
    +                    target: '_blank',
    +                    rel: 'noopener noreferrer'
    +                })
    +            )
    +        )
    +    );
     }
     
     function calculatePathsBetweenNodes(startNodeId, endNodeId) {
    @@ -1384,118 +1387,109 @@ <h6>${node.name}</h6>
         const sourceNode = currentGraph.nodes.find(n => n.id === link.source.id || n.id === link.source);
         const targetNode = currentGraph.nodes.find(n => n.id === link.target.id || n.id === link.target);
     
    -    const content = `
    -        <div class="row">
    -            <div class="col-12">
    -                <h6>${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.name : link.target}</h6>
    -                <p class="text-muted">${link.type === 'direct' ? 'Direct RF Link' : 'Multi-hop Connection'}</p>
    -            </div>
    -        </div>
    -        <div class="row">
    -            <div class="col-6">
    -                <strong>Average SNR:</strong><br>
    -                <span class="h5 ${link.avg_snr > -10 ? 'text-success' : 'text-warning'}">${link.avg_snr} dB</span>
    -            </div>
    -            <div class="col-6">
    -                <strong>Packets:</strong><br>
    -                <span class="h5 text-info">${link.packet_count || link.path_count || 0}</span>
    -            </div>
    -        </div>
    -        ${link.hop_count ? `
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <strong>Hop Count:</strong><br>
    -                <span class="h6 text-primary">${link.hop_count}</span>
    -            </div>
    -        </div>
    -        ` : ''}
    -        <div class="row mt-2">
    -            <div class="col-6">
    -                <strong>Last Seen:</strong><br>
    -                <small class="text-muted">${new Date(link.last_seen * 1000).toLocaleString()}</small>
    -            </div>
    -            <div class="col-6">
    -                <a href="/traceroute-hops?from_node=${link.source.id}&to_node=${link.target.id}" class="btn btn-outline-primary btn-sm">
    -                    <i class="bi bi-diagram-3"></i> View Link
    -                </a>
    -            </div>
    -        </div>
    -    `;
    -
    -    document.getElementById('selectedDetailsContent').innerHTML = content;
    -    document.getElementById('selectedDetails').style.display = 'block';
    +    setSelectedDetails('Selected Link',
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-12' },
    +                el('h6', null, `${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.name : link.target}`),
    +                el('p', { className: 'text-muted' }, link.type === 'direct' ? 'Direct RF Link' : 'Multi-hop Connection')
    +            )
    +        ),
    +        el('div', { className: 'row' },
    +            el('div', { className: 'col-6' },
    +                el('strong', null, 'Average SNR:'),
    +                el('br'),
    +                el('span', { className: `h5 ${link.avg_snr > -10 ? 'text-success' : 'text-warning'}` }, `${link.avg_snr} dB`)
    +            ),
    +            el('div', { className: 'col-6' },
    +                el('strong', null, 'Packets:'),
    +                el('br'),
    +                el('span', { className: 'h5 text-info' }, String(link.packet_count || link.path_count || 0))
    +            )
    +        ),
    +        link.hop_count ? el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-6' }, el('strong', null, 'Hop Count:'), el('br'), el('span', { className: 'h6 text-primary' }, String(link.hop_count)))
    +        ) : null,
    +        el('div', { className: 'row mt-2' },
    +            el('div', { className: 'col-6' },
    +                el('strong', null, 'Last Seen:'),
    +                el('br'),
    +                el('small', { className: 'text-muted' }, new Date(link.last_seen * 1000).toLocaleString())
    +            ),
    +            el('div', { className: 'col-6' },
    +                buttonLink({
    +                    href: safePath('/traceroute-hops', { from_node: link.source.id, to_node: link.target.id }),
    +                    className: 'btn btn-outline-primary btn-sm',
    +                    iconClass: 'bi bi-diagram-3',
    +                    text: 'View Link'
    +                })
    +            )
    +        )
    +    );
     }
     
     function showNodeHoverDetails(node) {
         const nodeIdHex = node.id.toString(16).padStart(8, '0');
     
    -    let pathInfo = '';
    +    let pathInfoNode = null;
         if (selectedNode && hopFilterEnabled && nodeDistances && node.id !== selectedNode.id) {
             const distance = nodeDistances.distances.get(node.id);
             if (distance !== undefined) {
                 const allPaths = getAllPathsToNode(node.id, nodeDistances.parents, selectedNode.id);
     
                 if (allPaths.length > 0) {
    -                pathInfo = `<div class="mb-2"><strong>Hops from ${selectedNode.name}:</strong> ${distance}</div>`;
    +                const pathParts = [el('div', { className: 'mb-2' }, el('strong', null, `Hops from ${selectedNode.name}:`), textNode(` ${distance}`))];
     
                     if (allPaths.length === 1) {
    -                    // Single path
                         const path = allPaths[0];
    -                    const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' → ');
    -                    pathInfo += `<div class="small text-muted" style="max-height: 150px; overflow-y: auto;">${pathNames}</div>`;
    +                    const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' -> '.replace('->', '→'));
    +                    pathParts.push(el('div', { className: 'small text-muted', style: { maxHeight: '150px', overflowY: 'auto' } }, pathNames));
                     } else {
    -                    // Multiple paths
    -                    pathInfo += `<div class="small"><strong>${allPaths.length} shortest paths:</strong></div>`;
    -                    pathInfo += `<div class="small text-muted" style="max-height: 150px; overflow-y: auto;">`;
    +                    const pathList = el('div', { className: 'small text-muted', style: { maxHeight: '150px', overflowY: 'auto' } });
    +                    pathParts.push(el('div', { className: 'small' }, el('strong', null, `${allPaths.length} shortest paths:`)));
     
                         allPaths.forEach((path, idx) => {
    -                        const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' → ');
    -                        pathInfo += `<div class="mb-1">${idx + 1}. ${pathNames}</div>`;
    +                        const pathNames = path.map(nodeId => getNodeNameById(nodeId)).join(' -> '.replace('->', '→'));
    +                        pathList.appendChild(el('div', { className: 'mb-1' }, `${idx + 1}. ${pathNames}`));
                         });
    -
    -                    pathInfo += `</div>`;
    +                    pathParts.push(pathList);
                     }
    +
    +                pathInfoNode = fragment(pathParts);
                 }
             }
         }
     
    -    const content = `
    -        <div><strong>${node.name}</strong></div>
    -        <div class="text-muted">!${nodeIdHex}</div>
    -        <hr class="my-2">
    -        ${pathInfo}
    -        ${pathInfo ? '<hr class="my-2">' : ''}
    -        <div><strong>Connections:</strong> ${node.connections}</div>
    -        <div><strong>Packets:</strong> ${node.packet_count}</div>
    -        <div><strong>Avg SNR:</strong> ${node.avg_snr ? node.avg_snr + ' dB' : 'N/A'}</div>
    -        <div><strong>Last Seen:</strong><br>
    -        <small>${new Date(node.last_seen * 1000).toLocaleString()}</small></div>
    -    `;
    -
    -    document.getElementById('hoverDetails').innerHTML = content;
    +    setHoverDetails(
    +        el('div', null, el('strong', null, node.name)),
    +        el('div', { className: 'text-muted' }, `!${nodeIdHex}`),
    +        el('hr', { className: 'my-2' }),
    +        pathInfoNode,
    +        pathInfoNode ? el('hr', { className: 'my-2' }) : null,
    +        el('div', null, el('strong', null, 'Connections:'), textNode(` ${node.connections}`)),
    +        el('div', null, el('strong', null, 'Packets:'), textNode(` ${node.packet_count}`)),
    +        el('div', null, el('strong', null, 'Avg SNR:'), textNode(` ${node.avg_snr ? `${node.avg_snr} dB` : 'N/A'}`)),
    +        el('div', null, el('strong', null, 'Last Seen:'), el('br'), el('small', null, new Date(node.last_seen * 1000).toLocaleString()))
    +    );
     }
     
     function showLinkHoverDetails(link) {
         const sourceNode = currentGraph.nodes.find(n => n.id === link.source.id || n.id === link.source);
         const targetNode = currentGraph.nodes.find(n => n.id === link.target.id || n.id === link.target);
     
    -    const content = `
    -        <div><strong>Link</strong></div>
    -        <div class="text-muted">${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.name : link.target}</div>
    -        <hr class="my-2">
    -        <div><strong>Type:</strong> ${link.type === 'direct' ? 'Direct RF' : 'Multi-hop'}</div>
    -        <div><strong>Avg SNR:</strong> ${link.avg_snr} dB</div>
    -        <div><strong>Packets:</strong> ${link.packet_count || link.path_count || 0}</div>
    -        ${link.hop_count ? `<div><strong>Hops:</strong> ${link.hop_count}</div>` : ''}
    -        <div><strong>Last Seen:</strong><br>
    -        <small>${new Date(link.last_seen * 1000).toLocaleString()}</small></div>
    -    `;
    -
    -    document.getElementById('hoverDetails').innerHTML = content;
    +    setHoverDetails(
    +        el('div', null, el('strong', null, 'Link')),
    +        el('div', { className: 'text-muted' }, `${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.name : link.target}`),
    +        el('hr', { className: 'my-2' }),
    +        el('div', null, el('strong', null, 'Type:'), textNode(` ${link.type === 'direct' ? 'Direct RF' : 'Multi-hop'}`)),
    +        el('div', null, el('strong', null, 'Avg SNR:'), textNode(` ${link.avg_snr} dB`)),
    +        el('div', null, el('strong', null, 'Packets:'), textNode(` ${link.packet_count || link.path_count || 0}`)),
    +        link.hop_count ? el('div', null, el('strong', null, 'Hops:'), textNode(` ${link.hop_count}`)) : null,
    +        el('div', null, el('strong', null, 'Last Seen:'), el('br'), el('small', null, new Date(link.last_seen * 1000).toLocaleString()))
    +    );
     }
     
     function clearHoverDetails() {
    -    document.getElementById('hoverDetails').innerHTML = '<small class="text-muted">Hover over nodes or links for details</small>';
    +    setHoverDetails(el('small', { className: 'text-muted' }, 'Hover over nodes or links for details'));
     }
     
     function getNodeName(nodeRef) {
    @@ -1620,12 +1614,11 @@ <h6>${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.
     }
     
     function updateStats(stats) {
    -    const statsContent = `
    -        <div><strong>Links:</strong> ${stats.links_found}</div>
    -        <div><strong>Packets:</strong> ${stats.packets_with_rf_hops}</div>
    -        <div><strong>RF Hops:</strong> ${stats.total_rf_hops}</div>
    -    `;
    -    document.getElementById('graphStats').innerHTML = statsContent;
    +    setChildren(document.getElementById('graphStats'),
    +        el('div', null, el('strong', null, 'Links:'), textNode(` ${stats.links_found}`)),
    +        el('div', null, el('strong', null, 'Packets:'), textNode(` ${stats.packets_with_rf_hops}`)),
    +        el('div', null, el('strong', null, 'RF Hops:'), textNode(` ${stats.total_rf_hops}`))
    +    );
     }
     
     // Handle window resize
    @@ -1894,7 +1887,7 @@ <h6>${sourceNode ? sourceNode.name : link.source} ↔ ${targetNode ? targetNode.
             if (data.channels) {
                 const select = document.getElementById('primary_channel');
                 const allOption = select.querySelector('option[value=""]');
    -            select.innerHTML = '';
    +            select.replaceChildren();
                 select.appendChild(allOption);
                 data.channels.forEach((ch) => {
                     const option = document.createElement('option');
    
  • src/malla/templates/traceroute_hops.html+182 123 modified
    @@ -279,7 +279,7 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
             }
     
             // Show loading state
    -        node2Select.innerHTML = '<option value="">Loading related nodes...</option>';
    +        node2Select.replaceChildren(new Option('Loading related nodes...', ''));
             node2Select.disabled = true;
     
             return fetch(`/api/traceroute/related-nodes/${nodeId}`)
    @@ -359,7 +359,7 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
             const currentValue = node2Select.value;
     
             // Clear existing options (except the first one)
    -        node2Select.innerHTML = '<option value="">Choose second node...</option>';
    +        node2Select.replaceChildren(new Option('Choose second node...', ''));
     
             nodes.forEach(node => {
                 // Create option text with traceroute count if available
    @@ -382,20 +382,13 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
             const infoDiv = document.getElementById(`${nodeType}-info`);
     
             if (!nodeId) {
    -            infoDiv.innerHTML = '';
    +            infoDiv.replaceChildren();
                 return;
             }
     
             const node = availableNodes.find(n => n.node_id == nodeId);
             if (node) {
    -            let info = `<strong>${node.hex_id}</strong>`;
    -            if (node.location) {
    -                info += ` • <i class="bi bi-geo-alt"></i> ${node.location.latitude.toFixed(4)}, ${node.location.longitude.toFixed(4)}`;
    -            }
    -            if (node.hw_model) {
    -                info += ` • ${node.hw_model}`;
    -            }
    -            infoDiv.innerHTML = info;
    +            setChildren(infoDiv, buildNodeInfoContent(node));
             }
         }
     
    @@ -404,23 +397,43 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
             const node = availableNodes.find(n => n.node_id == nodeId);
     
             if (node) {
    -            let info = `<strong>${node.hex_id}</strong>`;
    -            if (node.location) {
    -                info += ` • <i class="bi bi-geo-alt"></i> ${node.location.latitude.toFixed(4)}, ${node.location.longitude.toFixed(4)}`;
    -            }
    -            if (node.hw_model) {
    -                info += ` • ${node.hw_model}`;
    -            }
    +            setChildren(infoDiv, buildNodeInfoContent(node, relatedCount));
    +        }
    +    }
     
    -            // Add related nodes count
    -            if (relatedCount > 0) {
    -                info += ` • <i class="bi bi-diagram-3"></i> ${relatedCount} related nodes`;
    -            } else {
    -                info += ` • <i class="bi bi-exclamation-triangle text-warning"></i> No related nodes found`;
    -            }
    +    function buildInfoSeparator() {
    +        return el('span', { className: 'mx-1 text-muted' }, '•');
    +    }
     
    -            infoDiv.innerHTML = info;
    +    function buildNodeInfoContent(node, relatedCount = null) {
    +        const content = fragment(el('strong', node.hex_id));
    +
    +        if (node.location) {
    +            appendChildren(
    +                content,
    +                buildInfoSeparator(),
    +                icon('bi bi-geo-alt'),
    +                ' ',
    +                `${node.location.latitude.toFixed(4)}, ${node.location.longitude.toFixed(4)}`
    +            );
             }
    +
    +        if (node.hw_model) {
    +            appendChildren(content, buildInfoSeparator(), node.hw_model);
    +        }
    +
    +        if (relatedCount !== null) {
    +            const relatedIconClass = relatedCount > 0
    +                ? 'bi bi-diagram-3'
    +                : 'bi bi-exclamation-triangle text-warning';
    +            const relatedLabel = relatedCount > 0
    +                ? `${relatedCount} related nodes`
    +                : 'No related nodes found';
    +
    +            appendChildren(content, buildInfoSeparator(), icon(relatedIconClass), ' ', relatedLabel);
    +        }
    +
    +        return content;
         }
     
         function analyzeHop() {
    @@ -505,20 +518,25 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
     
         function createNodeLink(nodeId, displayText, includeTooltip = true) {
             const nodeInfo = getNodeDisplayInfo(nodeId);
    -        let tooltip = '';
    +        const tooltipLines = [nodeInfo.name];
    +
    +        if (nodeInfo.location) {
    +            tooltipLines.push(`Location: ${nodeInfo.location.latitude.toFixed(4)}, ${nodeInfo.location.longitude.toFixed(4)}`);
    +        }
    +
    +        if (nodeInfo.hw_model) {
    +            tooltipLines.push(`Hardware: ${nodeInfo.hw_model}`);
    +        }
     
             if (includeTooltip) {
    -            tooltip = `title="${nodeInfo.name}`;
    -            if (nodeInfo.location) {
    -                tooltip += `\nLocation: ${nodeInfo.location.latitude.toFixed(4)}, ${nodeInfo.location.longitude.toFixed(4)}`;
    -            }
    -            if (nodeInfo.hw_model) {
    -                tooltip += `\nHardware: ${nodeInfo.hw_model}`;
    -            }
    -            tooltip += `\nClick to view details"`;
    +            tooltipLines.push('Click to view details');
             }
     
    -        return `<a href="/node/${nodeId}" ${tooltip} class="text-decoration-none">${displayText || nodeInfo.name}</a>`;
    +        return nodeLink(nodeId, displayText || nodeInfo.name, {
    +            className: 'text-decoration-none node-link',
    +            tooltip: includeTooltip,
    +            title: tooltipLines.join('\n')
    +        });
         }
     
         function formatNodeIdForDisplay(nodeId) {
    @@ -586,34 +604,39 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
         function displayDirectionInfo(directionCounts, fromNodeId, toNodeId) {
             const directionDiv = document.getElementById('direction-info');
     
    -        // Create formatted node links for the direction display
    -        const fromNodeLink = createNodeLink(fromNodeId, formatNodeIdForDisplay(fromNodeId));
    -        const toNodeLink = createNodeLink(toNodeId, formatNodeIdForDisplay(toNodeId));
    -
    -        let html = `<strong>Link Analysis:</strong> `;
    -        html += `${fromNodeLink} ↔ ${toNodeLink}<br>`;
    -
             const directions = Object.keys(directionCounts);
    +        const content = fragment(
    +            el('strong', 'Link Analysis:'),
    +            ' ',
    +            createNodeLink(fromNodeId, formatNodeIdForDisplay(fromNodeId)),
    +            ' ↔ ',
    +            createNodeLink(toNodeId, formatNodeIdForDisplay(toNodeId)),
    +            el('br')
    +        );
    +
             if (directions.length > 1) {
    -            html += '<span class="badge bg-success signal-badge">Bidirectional Link</span>';
    +            appendChildren(content, badge('Bidirectional Link', 'bg-success signal-badge'));
             } else if (directions.length === 1) {
    -            html += '<span class="badge bg-warning signal-badge">Unidirectional Link</span>';
    +            appendChildren(content, badge('Unidirectional Link', 'bg-warning signal-badge'));
             } else {
    -            html += '<span class="badge bg-secondary signal-badge">No Data</span>';
    +            appendChildren(content, badge('No Data', 'bg-secondary signal-badge'));
             }
     
    -        // Show traffic distribution if bidirectional
             if (directions.length > 1) {
    -            html += '<br><small class="text-muted mt-1 d-block">';
    -            directions.forEach(direction => {
    +            const details = el('small', { className: 'text-muted mt-1 d-block' });
    +            const total = Object.values(directionCounts).reduce((a, b) => a + b, 0);
    +            directions.forEach((direction, index) => {
                     const count = directionCounts[direction];
    -                const percentage = (count / Object.values(directionCounts).reduce((a, b) => a + b, 0) * 100).toFixed(1);
    -                html += `${direction}: ${count} traceroutes (${percentage}%)<br>`;
    +                const percentage = (count / total * 100).toFixed(1);
    +                if (index > 0) {
    +                    details.appendChild(el('br'));
    +                }
    +                details.append(`${direction}: ${count} traceroutes (${percentage}%)`);
                 });
    -            html += '</small>';
    +            appendChildren(content, el('br'), details);
             }
     
    -        directionDiv.innerHTML = html;
    +        setChildren(directionDiv, content);
         }
     
         function updateSNRChart(traceroutes) {
    @@ -728,17 +751,17 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
             const listDiv = document.getElementById('traceroute-list');
     
             if (traceroutes.length === 0) {
    -            listDiv.innerHTML = '<p class="text-muted">No traceroutes found.</p>';
    +            setChildren(listDiv, el('p', { className: 'text-muted' }, 'No traceroutes found.'));
                 return;
             }
     
    -        let html = '';
    +        const cards = [];
             traceroutes.forEach((tr, index) => {
                 const snrBadgeClass = getSNRBadgeClass(tr.hop_snr);
                 const snrText = tr.hop_snr !== null ? `${tr.hop_snr.toFixed(1)} dB` : 'N/A';
     
                 // Create route display using structured data
    -            let routeDisplay = '';
    +            let routeDisplay;
                 if (tr.route_hops && tr.route_hops.length > 0) {
                     // Filter out the last hop if it has no SNR (indicating it's incomplete)
                     const hopsToShow = tr.route_hops.filter((hop, hopIndex) => {
    @@ -749,101 +772,137 @@ <h4 class="mt-3">No RF Hop Data Found</h4>
                         return true;
                     });
     
    -                routeDisplay = '<div class="table-responsive"><table class="table table-sm table-borderless mb-0" style="font-size: 0.85em;">';
    -                routeDisplay += '<thead><tr class="text-muted" style="font-size: 0.75em;">';
    -                routeDisplay += '<th style="width: 12%;">Hop</th>';
    -                routeDisplay += '<th style="width: 30%;">From</th>';
    -                routeDisplay += '<th style="width: 30%;">To</th>';
    -                routeDisplay += '<th style="width: 13%;">SNR</th>';
    -                routeDisplay += '<th style="width: 15%;">Direction</th>';
    -                routeDisplay += '</tr></thead><tbody>';
    +                const table = el('table', {
    +                    className: 'table table-sm table-borderless mb-0',
    +                    style: { fontSize: '0.85em' }
    +                });
    +                const theadRow = el('tr', {
    +                    className: 'text-muted',
    +                    style: { fontSize: '0.75em' }
    +                });
    +                [
    +                    ['Hop', '12%'],
    +                    ['From', '30%'],
    +                    ['To', '30%'],
    +                    ['SNR', '13%'],
    +                    ['Direction', '15%']
    +                ].forEach(([label, width]) => {
    +                    theadRow.appendChild(el('th', { style: { width } }, label));
    +                });
    +                table.appendChild(el('thead', theadRow));
    +                const tbody = el('tbody');
     
                     hopsToShow.forEach(hop => {
    -                    const rowClass = hop.is_target_hop ? 'table-primary' : '';
    -                    const fontWeight = hop.is_target_hop ? 'font-weight: bold; color: #0d6efd;' : '';
    +                    const row = el('tr', { className: hop.is_target_hop ? 'table-primary' : '' });
    +                    const nodeCellClass = hop.is_target_hop ? 'fw-bold text-primary' : '';
     
    -                    let snrDisplay = '—';
    +                    let snrDisplay = el('span', '—');
                         if (hop.snr !== null && hop.snr !== undefined) {
                             const snrClass = hop.snr >= 0 ? 'text-success' : 'text-danger';
    -                        snrDisplay = `<span class="${snrClass}">${hop.snr.toFixed(1)}</span>`;
    +                        snrDisplay = el('span', { className: snrClass }, hop.snr.toFixed(1));
                         }
     
    -                    // Create node links with proper formatting
    -                    const fromNodeLink = createNodeLink(hop.from_node_id, hop.from_node_name);
    -                    const toNodeLink = createNodeLink(hop.to_node_id, hop.to_node_name);
    -
    -                    // Format direction display
    -                    let directionDisplay = '—';
    +                    let directionDisplay = el('span', '—');
                         if (hop.direction) {
                             if (hop.direction === 'forward_rf') {
    -                            directionDisplay = '<span class="badge bg-primary" style="font-size: 0.7em;">Forward</span>';
    +                            directionDisplay = badge('Forward', 'bg-primary');
                             } else if (hop.direction === 'return_rf') {
    -                            directionDisplay = '<span class="badge bg-success" style="font-size: 0.7em;">Return</span>';
    +                            directionDisplay = badge('Return', 'bg-success');
                             } else {
    -                            directionDisplay = `<span class="badge bg-secondary" style="font-size: 0.7em;">${hop.direction}</span>`;
    +                            directionDisplay = badge(hop.direction, 'bg-secondary');
                             }
    +                        directionDisplay.style.fontSize = '0.7em';
                         }
     
    -                    routeDisplay += `<tr class="${rowClass}">`;
    -                    routeDisplay += `<td><small class="text-muted">#${hop.hop_number}</small></td>`;
    -                    routeDisplay += `<td style="${fontWeight}"><small>${fromNodeLink}</small></td>`;
    -                    routeDisplay += `<td style="${fontWeight}"><small>${toNodeLink}</small></td>`;
    -                    routeDisplay += `<td><small>${snrDisplay}</small></td>`;
    -                    routeDisplay += `<td><small>${directionDisplay}</small></td>`;
    -                    routeDisplay += '</tr>';
    +                    const hopLabel = el('small', { className: 'text-muted' }, `#${hop.hop_number}`);
    +                    const fromNode = el('small', { className: nodeCellClass || null });
    +                    fromNode.appendChild(createNodeLink(hop.from_node_id, hop.from_node_name));
    +                    const toNode = el('small', { className: nodeCellClass || null });
    +                    toNode.appendChild(createNodeLink(hop.to_node_id, hop.to_node_name));
    +                    const snrNode = el('small');
    +                    snrNode.appendChild(snrDisplay);
    +                    const directionNode = el('small');
    +                    directionNode.appendChild(directionDisplay);
    +
    +                    appendChildren(
    +                        row,
    +                        el('td', hopLabel),
    +                        el('td', fromNode),
    +                        el('td', toNode),
    +                        el('td', snrNode),
    +                        el('td', directionNode)
    +                    );
    +                    tbody.appendChild(row);
                     });
     
                     // Add warning row if we filtered out incomplete hops
                     if (hopsToShow.length < tr.route_hops.length) {
                         const filteredHop = tr.route_hops[tr.route_hops.length - 1];
    -                    const destinationNodeLink = createNodeLink(filteredHop.to_node_id, filteredHop.to_node_name, false);
    -                    routeDisplay += `<tr class="table-warning">`;
    -                    routeDisplay += `<td colspan="5" class="text-center" style="font-size: 0.8em;">`;
    -                    routeDisplay += `<i class="bi bi-exclamation-triangle"></i> `;
    -                    routeDisplay += `<strong>Incomplete</strong> - Last hop to ${destinationNodeLink} not confirmed`;
    -                    routeDisplay += `</td></tr>`;
    +                    const warningRow = el('tr', { className: 'table-warning' });
    +                    const warningCell = el('td', {
    +                        colspan: 5,
    +                        className: 'text-center',
    +                        style: { fontSize: '0.8em' }
    +                    });
    +                    appendChildren(
    +                        warningCell,
    +                        icon('bi bi-exclamation-triangle'),
    +                        ' ',
    +                        el('strong', 'Incomplete'),
    +                        ' - Last hop to ',
    +                        createNodeLink(filteredHop.to_node_id, filteredHop.to_node_name, false),
    +                        ' not confirmed'
    +                    );
    +                    warningRow.appendChild(warningCell);
    +                    tbody.appendChild(warningRow);
                     }
     
    -                routeDisplay += '</tbody></table></div>';
    +                table.appendChild(tbody);
    +                routeDisplay = el('div', { className: 'table-responsive' }, table);
                 } else {
                     // Fallback to complete_path_display if route_hops not available
    -                routeDisplay = tr.complete_path_display || 'Route data unavailable';
    +                routeDisplay = el('div', tr.complete_path_display || 'Route data unavailable');
                 }
     
    -            html += `
    -                <div class="card mb-2">
    -                    <div class="card-body p-3">
    -                        <div class="row align-items-center">
    -                            <div class="col-md-8">
    -                                <div class="d-flex align-items-center">
    -                                    <span class="badge ${snrBadgeClass} me-2">${snrText}</span>
    -                                    <small class="text-muted">${getRelativeTime(tr.timestamp)}</small>
    -                                </div>
    -                            </div>
    -                            <div class="col-md-4 text-md-end">
    -                                <div class="d-flex flex-column align-items-end">
    -                                    <small class="text-muted mb-1">
    -                                        <strong>From:</strong> ${createNodeLink(tr.from_node_id, tr.from_node_name)}
    -                                        ${tr.gateway_node_name ? `(via ${tr.gateway_node_name})` : ''}
    -                                    </small>
    -                                    <small class="text-muted mb-1">
    -                                        <strong>To:</strong> ${createNodeLink(tr.to_node_id, tr.to_node_name)}
    -                                    </small>
    -                                    ${tr.id ? `<a href="/packet/${tr.id}" class="btn btn-sm btn-outline-primary" title="View packet details">
    -                                        <i class="bi bi-info-circle"></i> Packet Details
    -                                    </a>` : ''}
    -                                </div>
    -                            </div>
    -                        </div>
    -                        <div class="mt-2">
    -                            ${routeDisplay}
    -                        </div>
    -                    </div>
    -                </div>
    -            `;
    +            const fromInfo = el('small', { className: 'text-muted mb-1' });
    +            appendChildren(fromInfo, el('strong', 'From:'), ' ', createNodeLink(tr.from_node_id, tr.from_node_name));
    +            if (tr.gateway_node_name) {
    +                fromInfo.append(` (via ${tr.gateway_node_name})`);
    +            }
    +
    +            const toInfo = el('small', { className: 'text-muted mb-1' });
    +            appendChildren(toInfo, el('strong', 'To:'), ' ', createNodeLink(tr.to_node_id, tr.to_node_name));
    +
    +            const actions = el('div', { className: 'd-flex flex-column align-items-end' }, fromInfo, toInfo);
    +            if (tr.id) {
    +                actions.appendChild(buttonLink({
    +                    href: safePath(`/packet/${tr.id}`),
    +                    className: 'btn btn-sm btn-outline-primary',
    +                    title: 'View packet details',
    +                    iconClass: 'bi bi-info-circle',
    +                    text: 'Packet Details'
    +                }));
    +            }
    +
    +            cards.push(
    +                el('div', { className: 'card mb-2' },
    +                    el('div', { className: 'card-body p-3' },
    +                        el('div', { className: 'row align-items-center' },
    +                            el('div', { className: 'col-md-8' },
    +                                el('div', { className: 'd-flex align-items-center' },
    +                                    badge(snrText, `${snrBadgeClass} me-2`),
    +                                    el('small', { className: 'text-muted' }, getRelativeTime(tr.timestamp))
    +                                )
    +                            ),
    +                            el('div', { className: 'col-md-4 text-md-end' }, actions)
    +                        ),
    +                        el('div', { className: 'mt-2' }, routeDisplay)
    +                    )
    +                )
    +            );
             });
     
    -        listDiv.innerHTML = html;
    +        setChildren(listDiv, cards);
     
             // Re-initialize tooltips for the new content
             reinitializeTooltips();
    
  • src/malla/templates/traceroute.html+54 86 modified
    @@ -213,17 +213,10 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.from_node_id) {
    -                        // Use the short name from API response
                             const shortName = row.from_node_short || `${row.from_node_id.toString(16).padStart(8, '0')}`.slice(-4);
    -                        return `<a href="/node/${row.from_node_id}" class="text-decoration-none node-link"
    -                                   data-node-id="${row.from_node_id}" data-bs-toggle="tooltip"
    -                                   data-bs-placement="top" data-bs-html="true"
    -                                   data-bs-title="Loading..." title="View node details">
    -                                    ${shortName}
    -                                </a>`;
    -                    } else {
    -                        return value || 'Unknown';
    +                        return nodeLink(row.from_node_id, shortName);
                         }
    +                    return textNode(value || 'Unknown');
                     }
                 },
                 {
    @@ -232,48 +225,32 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.to_node_id && row.to_node_id !== 4294967295) {
    -                        // Use the short name from API response
                             const shortName = row.to_node_short || row.to_node_id.toString(16).padStart(8, '0').slice(-4);
    -                        return `<a href="/node/${row.to_node_id}" class="text-decoration-none node-link"
    -                                   data-node-id="${row.to_node_id}" data-bs-toggle="tooltip"
    -                                   data-bs-placement="top" data-bs-html="true"
    -                                   data-bs-title="Loading..." title="View node details">
    -                                    ${shortName}
    -                                </a>`;
    -                    } else {
    -                        return value || 'Broadcast';
    +                        return nodeLink(row.to_node_id, shortName);
                         }
    +                    return textNode(value || 'Broadcast');
                     }
                 },
                             {
                     key: 'route',
                     title: 'Route',
                     sortable: false,
                     render: (value, row) => {
    -                    // Build route display client-side from structured data
                         if (row.route_nodes && row.route_names &&
                             Array.isArray(row.route_nodes) && Array.isArray(row.route_names) &&
                             row.route_nodes.length > 0 && row.route_names.length > 0) {
    -
    -                        // Create clickable links for each node in the route
                             const routeParts = row.route_names.map((nodeName, index) => {
                                 const nodeId = row.route_nodes[index];
                                 if (nodeId) {
    -                                return `<a href="/node/${nodeId}" class="text-decoration-none node-link"
    -                                           data-node-id="${nodeId}" data-bs-toggle="tooltip"
    -                                           data-bs-placement="top" data-bs-html="true"
    -                                           data-bs-title="Loading..." title="View node details">
    -                                            ${nodeName}
    -                                        </a>`;
    +                                return nodeLink(nodeId, nodeName);
                                 }
    -                            return nodeName;
    +                            return textNode(nodeName);
                             });
     
    -                        return routeParts.join(' → ');
    +                        return joinNodes(routeParts, ' -> '.replace('->', '→'));
                         }
     
    -                    // Fallback for no route data
    -                    return `<span class="text-muted">No route data</span>`;
    +                    return el('span', { className: 'text-muted' }, 'No route data');
                     }
                 },
                 {
    @@ -282,43 +259,30 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        // For grouped packets, show gateway count
                             const count = row.gateway_count;
                             if (count > 1) {
    -                            return `<span class="badge bg-info" title="Multiple gateways: ${row.gateway_list}">
    -                                                ${count} gateways
    -                                            </span>`;
    +                            return el('span', {
    +                                className: 'badge bg-info',
    +                                title: `Multiple gateways: ${row.gateway_list}`
    +                            }, `${count} gateways`);
                             } else if (count === 1) {
    -                            return `<span class="badge bg-info">1 gateway</span>`;
    +                            return badge('1 gateway', 'bg-info');
                             } else {
    -                            return `<span class="text-muted">N/A</span>`;
    +                            return el('span', { className: 'text-muted' }, 'N/A');
                             }
                         } else {
    -                        // For individual packets, show gateway short name with link
                             const gatewayName = row.gateway_name;
                             const gatewayNodeId = row.gateway_node_id;
     
                             if (value && value.startsWith('!') && gatewayNodeId) {
    -                            // Use last 4 hex digits as short name
                                 const shortName = value.substring(value.length - 4).toUpperCase();
    -                            return `<a href="/node/${gatewayNodeId}" class="text-decoration-none node-link"
    -                                       data-node-id="${gatewayNodeId}" data-bs-toggle="tooltip"
    -                                       data-bs-placement="top" data-bs-html="true"
    -                                       data-bs-title="Loading..." title="View node details">
    -                                        ${shortName}
    -                                    </a>`;
    +                            return nodeLink(gatewayNodeId, shortName);
                             } else if (gatewayName && gatewayNodeId) {
    -                            // Extract short name from gateway name if available
                                 const parenMatch = gatewayName.match(/\(([^)]+)\)$/);
                                 const shortName = parenMatch ? parenMatch[1] : gatewayName.substring(0, 4).toUpperCase();
    -                            return `<a href="/node/${gatewayNodeId}" class="text-decoration-none node-link"
    -                                       data-node-id="${gatewayNodeId}" data-bs-toggle="tooltip"
    -                                       data-bs-placement="top" data-bs-html="true"
    -                                       data-bs-title="Loading..." title="View node details">
    -                                        ${shortName}
    -                                    </a>`;
    +                            return nodeLink(gatewayNodeId, shortName);
                             }
    -                        return value || 'N/A';
    +                        return textNode(value || 'N/A');
                         }
                     }
                 },
    @@ -328,16 +292,16 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span>${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', null, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '' && value !== 'N/A') {
                             const rssiValue = parseFloat(value);
                             const colorClass = getRssiColorClass(rssiValue);
                             const formattedValue = rssiValue.toFixed(1);
    -                        return `<span class="${colorClass}">${formattedValue} dBm</span>`;
    +                        return el('span', { className: colorClass }, `${formattedValue} dBm`);
                         }
    -                    return '<span class="text-muted">N/A</span>';
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
    @@ -346,16 +310,16 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span>${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', null, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '' && value !== 'N/A') {
                             const snrValue = parseFloat(value);
                             const colorClass = getSnrColorClass(snrValue);
                             const formattedValue = snrValue.toFixed(2);
    -                        return `<span class="${colorClass}">${formattedValue} dB</span>`;
    +                        return el('span', { className: colorClass }, `${formattedValue} dB`);
                         }
    -                    return '<span class="text-muted">N/A</span>';
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
    @@ -364,28 +328,27 @@
                     sortable: true,
                     render: (value, row) => {
                         if (row.is_grouped) {
    -                        return value && value !== 'N/A' ?
    -                            `<span>${value}</span>` :
    -                            `<span class="text-muted">N/A</span>`;
    +                        return value && value !== 'N/A'
    +                            ? el('span', null, value)
    +                            : el('span', { className: 'text-muted' }, 'N/A');
                         } else if (value !== null && value !== '') {
                             const hops = parseInt(value);
                             if (hops === 0) {
    -                            return `<span class="badge bg-success">Direct</span>`;
    +                            return badge('Direct', 'bg-success');
                             } else if (hops <= 2) {
    -                            return `<span class="badge bg-info">${hops}</span>`;
    +                            return badge(String(hops), 'bg-info');
                             } else {
    -                            return `<span class="badge bg-warning">${hops}</span>`;
    +                            return badge(String(hops), 'bg-warning');
                             }
                         }
    -                    return `<span class="text-muted">N/A</span>`;
    +                    return el('span', { className: 'text-muted' }, 'N/A');
                     }
                 },
                 {
                     key: 'id',
                     title: 'Actions',
                     sortable: false,
                     render: (value, row) => {
    -                    // Create filtered URLs using the URL manager
                         const hopsUrl = urlManager.createFilteredURL('/traceroute-hops', {
                             from_node: row.from_node_id,
                             to_node: row.to_node_id
    @@ -395,21 +358,26 @@
                             to_node: row.to_node_id
                         });
     
    -                    return `
    -                        <div class="btn-group" role="group">
    -                            <a href="/packet/${value}"
    -                               class="btn btn-sm btn-outline-info" title="View packet details">
    -                                <i class="bi bi-info-circle"></i>
    -                            </a>
    -                            <a href="${hopsUrl}"
    -                               class="btn btn-sm btn-outline-secondary" title="Analyze hops between these nodes">
    -                                <i class="bi bi-diagram-3"></i>
    -                            </a>
    -                            <a href="${packetsUrl}"
    -                               class="btn btn-sm btn-outline-primary" title="View packets between these nodes">
    -                                <i class="bi bi-arrow-left-right"></i>
    -                            </a>
    -                        </div>`;
    +                    return el('div', { className: 'btn-group', role: 'group' },
    +                        buttonLink({
    +                            href: safePath(`/packet/${encodeURIComponent(String(value))}`),
    +                            className: 'btn btn-sm btn-outline-info',
    +                            title: 'View packet details',
    +                            iconClass: 'bi bi-info-circle'
    +                        }),
    +                        buttonLink({
    +                            href: hopsUrl,
    +                            className: 'btn btn-sm btn-outline-secondary',
    +                            title: 'Analyze hops between these nodes',
    +                            iconClass: 'bi bi-diagram-3'
    +                        }),
    +                        buttonLink({
    +                            href: packetsUrl,
    +                            className: 'btn btn-sm btn-outline-primary',
    +                            title: 'View packets between these nodes',
    +                            iconClass: 'bi bi-arrow-left-right'
    +                        })
    +                    );
                     }
                 }
             ]
    
  • tests/unit/test_mqtt_client_id.py+17 0 modified
    @@ -5,6 +5,7 @@
     
     import os
     import sys
    +from threading import Event
     from unittest.mock import MagicMock, patch
     
     sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../src"))
    @@ -82,16 +83,24 @@ def test_main_uses_configured_client_id(
             mock_mqtt_client_class.return_value = mock_client
             mock_client.connect.return_value = None
             mock_client.loop_start.return_value = None
    +        stop_main = Event()
    +
    +        def stop_after_first_sleep(_seconds):
    +            stop_main.set()
    +            raise KeyboardInterrupt()
     
             with (
                 patch("malla.mqtt_capture.MQTT_CLIENT_ID", "test-client-id"),
                 patch("malla.mqtt_capture.MQTT_USERNAME", None),
                 patch("malla.mqtt_capture.cleanup_thread", None),
    +            patch("malla.mqtt_capture.time.sleep", side_effect=stop_after_first_sleep),
             ):
                 from malla.mqtt_capture import main
     
                 main()
     
    +        assert stop_main.is_set()
    +
             mock_mqtt_client_class.assert_called_once_with(
                 CallbackAPIVersion.VERSION2, client_id="test-client-id"
             )
    @@ -119,16 +128,24 @@ def test_main_uses_empty_string_when_no_client_id(
             mock_mqtt_client_class.return_value = mock_client
             mock_client.connect.return_value = None
             mock_client.loop_start.return_value = None
    +        stop_main = Event()
    +
    +        def stop_after_first_sleep(_seconds):
    +            stop_main.set()
    +            raise KeyboardInterrupt()
     
             with (
                 patch("malla.mqtt_capture.MQTT_CLIENT_ID", None),
                 patch("malla.mqtt_capture.MQTT_USERNAME", None),
                 patch("malla.mqtt_capture.cleanup_thread", None),
    +            patch("malla.mqtt_capture.time.sleep", side_effect=stop_after_first_sleep),
             ):
                 from malla.mqtt_capture import main
     
                 main()
     
    +        assert stop_main.is_set()
    +
             mock_mqtt_client_class.assert_called_once_with(
                 CallbackAPIVersion.VERSION2, client_id=""
             )
    

Vulnerability mechanics

Root cause

"Node names are stored in SQLite and rendered into the DOM without proper sanitization or escaping."

Attack vector

An unauthenticated remote attacker can publish a Meshtastic NODEINFO_APP packet with a malicious `long_name` or `short_name` to a public Meshtastic MQTT broker. This malicious name, containing HTML entities or JavaScript code, is then stored in the SQLite database. When other users view the Malla dashboard, the unsanitized node name is rendered directly into the DOM, executing arbitrary JavaScript in their browsers [ref_id=2]. This can lead to phishing overlays, forced redirects, or script injection.

Affected code

The vulnerability affects multiple HTML template files where node names are rendered: `src/malla/templates/traceroute_graph.html` (line ~832), `src/malla/templates/map.html` (lines ~945, 1078), and `src/malla/templates/packet_detail.html` (lines ~1402, 1452). Additionally, the JavaScript file `src/malla/static/js/relay_node_analysis.js` (line ~124) is implicated in handling or rendering this data [ref_id=2].

What the fix does

The patch modifies the `ModernTable` class, specifically in `modern-table.js`, to ensure that data rendered into the DOM is properly escaped. While the provided diff focuses on general table rendering improvements and does not explicitly show sanitization of node names, the commit message 'Frontend fixes (#77)' and the associated issue suggest that this change addresses the vulnerability by preventing the execution of malicious JavaScript. The fix likely involves updating how data is processed before being inserted into the HTML structure, thereby mitigating the stored cross-site scripting (XSS) risk [patch_id=4693102].

Preconditions

  • networkAccess to a public Meshtastic MQTT broker.
  • inputThe attacker must be able to publish a Meshtastic NODEINFO_APP packet with a crafted `long_name` or `short_name`.

Reproduction

1. Publish a Meshtastic NODEINFO_APP packet to any public MQTT broker with `long_name` set to a HTML entity, for example: `<img src=x onerror=alert(1)>`. 2. Wait for `malla-capture` to store this packet. 3. Open the Malla dashboard to observe the JavaScript execution.

Generated on Jun 3, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.