';
el.appendChild(widget);
})
.catch(function () {});
}
}
function renderPayloadPie(types) {
const total = types.reduce((s, t) => s + t.count, 0);
const colors = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16'];
let html = '
';
types.forEach((t, i) => {
const pct = (t.count / total * 100).toFixed(1);
const w = Math.max(t.count / total * 100, 1);
html += `
${escapeHtml(t.name)}
${t.count} (${pct}%)
`;
});
return html + '
';
}
// === Relay Airtime Share — dumbbell chart (#1359) ===
// Two dots per payload_type row: gray = count %, colored = airtime %.
// Connector line between them = the divergence. Shared 0–100% axis.
// Sorted desc by airtime (server-side) so count dots visibly scatter
// out of rank order — that visible disorder IS the headline.
function renderRelayAirtimeDumbbell(data) {
var rows = (data && Array.isArray(data.rows)) ? data.rows : [];
if (!rows.length) {
return '
No relay activity observed in this window (all packets direct).
';
}
// Issue #1768 — surface the LoRa preset baked into the ToA score. Share
// numbers are only meaningful relative to one PHY preset; operators must
// know what was assumed. All fields are run through esc() defensively
// (preset.sf/cr/preamble/bw_khz/freq_hz arrive from the server JSON,
// which the operator can configure — never inject untrusted text raw).
// BW formatted consistent with home.js / customize-v2.js presets
// (e.g. `62.5 kHz`, `125 kHz`) — strip trailing `.0` for integer kHz.
var preset = data && data.preset;
var presetCaption = '';
if (preset && typeof preset === 'object') {
var freqMHz = Number(preset.freq_hz || 0) / 1e6;
var bwKhz = Number(preset.bw_khz || 0);
var bwStr = bwKhz ? bwKhz.toFixed(1).replace(/\.0$/, '') : '0';
presetCaption =
'
';
}
function renderBestPath(nodes) {
if (!nodes.length) return '
No data
';
// Group by distance for a cleaner view
const byDist = {};
nodes.forEach(n => {
if (!byDist[n.minDist]) byDist[n.minDist] = [];
byDist[n.minDist].push(n);
});
let html = '
';
}
// ===================== CHANNELS =====================
var _channelSortState = null;
var _channelData = null;
var _channelRenderGen = 0;
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
function loadChannelSort() {
try {
var s = localStorage.getItem(CHANNEL_SORT_KEY);
if (s) { var p = JSON.parse(s); if (p.col && p.dir) return p; }
} catch (e) {}
return { col: 'lastActivity', dir: 'desc' };
}
// True when the user has explicitly chosen a sort (saved in localStorage).
// Used by the grouped analytics view to decide whether to apply its own
// default ("messages desc") instead of the global flat-list default.
function hasSavedChannelSort() {
try {
var s = localStorage.getItem(CHANNEL_SORT_KEY);
if (!s) return false;
var p = JSON.parse(s);
return !!(p && p.col && p.dir);
} catch (e) { return false; }
}
function saveChannelSort(state) {
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
}
function sortChannels(channels, col, dir) {
var sorted = channels.slice();
var mult = dir === 'asc' ? 1 : -1;
sorted.sort(function (a, b) {
var av, bv;
switch (col) {
case 'name':
av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase();
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
case 'hash':
av = typeof a.hash === 'number' ? a.hash : String(a.hash);
bv = typeof b.hash === 'number' ? b.hash : String(b.hash);
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mult;
av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
case 'messages': return (a.messages - b.messages) * mult;
case 'senders': return (a.senders - b.senders) * mult;
case 'lastActivity':
av = a.lastActivity || ''; bv = b.lastActivity || '';
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
case 'encrypted':
av = a.encrypted ? 1 : 0; bv = b.encrypted ? 1 : 0;
return (av - bv) * mult;
default: return 0;
}
});
return sorted;
}
function channelRowHtml(c) {
// displayNameHtml: app-controlled raw HTML (e.g. the encrypted
// placeholder which carries a Phosphor sprite — set by
// decorateAnalyticsChannels for unknown encrypted rows). Same pattern
// as the section-header row: sprite-bearing labels must NOT be
// HTML-escaped (#1657 class of bug). For any other input we still
// go through esc() — never trust c.name / c.displayName as HTML.
var nameHtml = c.displayNameHtml
? c.displayNameHtml
: esc(c.displayName || c.name || 'Unknown');
return '
';
}
// ── PSK-aware decoration ──────────────────────────────────────────────────
// Server returns raw "chNNN" placeholder names for encrypted channels it
// doesn't know. Decorate so the UI shows a useful display name and a
// group bucket: mine / network / encrypted. Pure function for testability.
function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) {
var keyMap = hashByteToKeyName || {};
var lab = labels || {};
var out = [];
for (var i = 0; i < (channels || []).length; i++) {
var c = channels[i];
var copy = Object.assign({}, c);
var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10);
var rawName = String(c.name || '');
var isPlaceholder = /^ch(\d+|\?)$/.test(rawName);
if (c.encrypted) {
var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null;
if (keyName) {
copy.displayName = lab[keyName] || keyName;
copy.group = 'mine';
} else if (isPlaceholder || !rawName) {
// Placeholder ("chNNN") or empty name → render as opaque encrypted.
// Empty-name encrypted rows would otherwise leak through with an
// empty in the row; force the placeholder rendering.
//
// displayName carries a clean text label (no emoji, no markup) for
// screen readers / sort comparison / tests. displayNameHtml carries
// the rendered HTML with a Phosphor ph-lock sprite — consumed by
// channelRowHtml(). Two fields, one source of truth, no escaping
// surprises (v384-12.18 follow-up to #1648/#1657).
var hex = !isNaN(hashNum)
? ' (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
: '';
copy.displayName = 'Encrypted' + hex;
copy.displayNameHtml =
' Encrypted' + hex;
copy.group = 'encrypted';
} else {
// Server gave us a real name (rainbow table hit) for an encrypted ch.
copy.displayName = rawName;
copy.group = 'network';
}
} else {
copy.displayName = rawName || 'Unknown';
copy.group = 'network';
}
out.push(copy);
}
return out;
}
// Build the (hash byte → key name) map from ChannelDecrypt's stored keys.
// Async because computeChannelHash uses subtle.digest. Returns {} if the
// module or its keys are unavailable (graceful fallback).
async function buildHashKeyMap() {
if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {};
var keys = ChannelDecrypt.getStoredKeys();
var map = {};
var names = Object.keys(keys || {});
for (var ni = 0; ni < names.length; ni++) {
var name = names[ni];
try {
var bytes = ChannelDecrypt.hexToBytes(keys[name]);
var hb = await ChannelDecrypt.computeChannelHash(bytes);
if (typeof hb === 'number') map[hb] = name;
} catch (e) { /* skip bad key */ }
}
return map;
}
function channelTbodyHtml(channels, col, dir, opts) {
var sorted = sortChannels(channels, col, dir);
var parts = [];
if (opts && opts.grouped) {
// Group by .group: mine → network → encrypted. Inside each group keep
// the active sort (caller passes col/dir; for the integration we sort
// by messages desc by default).
var groups = { mine: [], network: [], encrypted: [] };
for (var gi = 0; gi < sorted.length; gi++) {
var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network');
(groups[g] || (groups[g] = [])).push(sorted[gi]);
}
var sections = [
{ key: 'mine', label: ' My Channels' },
{ key: 'network', label: ' Network' },
{ key: 'encrypted', label: ' Encrypted' },
];
for (var si = 0; si < sections.length; si++) {
var rows = groups[sections[si].key] || [];
if (!rows.length) continue;
parts.push(
'
' +
// sections[].label is a hardcoded sprite-bearing string (no user
// input) — must be inserted raw so the
'
);
for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri]));
}
} else {
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
}
return parts.join('');
}
function channelSortArrow(col, activeCol, dir) {
if (col !== activeCol) return '⇅';
return '' + (dir === 'asc' ? '↑' : '↓') + '';
}
function channelTheadHtml(activeCol, dir) {
var cols = [
{ key: 'name', label: 'Channel' },
{ key: 'hash', label: 'Hash' },
{ key: 'messages', label: 'Messages' },
{ key: 'senders', label: 'Unique Senders' },
{ key: 'lastActivity', label: 'Last Activity' },
{ key: 'encrypted', label: 'Decrypted' },
];
var ths = '';
for (var i = 0; i < cols.length; i++) {
var c = cols[i];
ths += '
';
}
function updateChannelTable() {
var tbody = document.getElementById('channelsTbody');
var thead = document.querySelector('#channelsTable thead');
if (!tbody || !_channelData) return;
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true });
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
}
function renderChannels(el, ch) {
// Decorate first so grouping/display name reflect locally-stored PSK keys.
// buildHashKeyMap is async; render once with a sync best-effort empty map,
// then upgrade once keys resolve. That keeps first paint fast and avoids
// blocking on subtle.digest in environments where it's slow.
var rawChannels = ch.channels || [];
// Resolve the persisted sort first so the default-fallback below doesn't
// shadow what the user previously chose. Default for the grouped view is
// messages desc (matches the PR description); only used when nothing saved.
if (!_channelSortState) {
_channelSortState = hasSavedChannelSort()
? loadChannelSort()
: { col: 'messages', dir: 'desc' };
}
var ranOnce = false;
// Generation token: if renderChannels is called again before
// buildHashKeyMap() resolves, the older promise must not clobber the
// newer rawChannels / decoration with stale-key data.
var myGen = ++_channelRenderGen;
function applyDecorate(map) {
if (myGen !== _channelRenderGen) return; // superseded
var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels)
? ChannelDecrypt.getLabels() : {};
_channelData = decorateAnalyticsChannels(rawChannels, map, labels);
if (ranOnce) updateChannelTable();
}
applyDecorate({});
ranOnce = true;
buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ });
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
var topSendersHtml = renderTopSenders(ch.topSenders);
var histoHtml = ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '
';
// Attach sort handler via delegation on the table
var table = document.getElementById('channelsTable');
if (table) {
table.addEventListener('click', function (e) {
var th = e.target.closest('th[data-sort-col]');
if (!th) return;
var col = th.dataset.sortCol;
if (_channelSortState.col === col) {
_channelSortState.dir = _channelSortState.dir === 'asc' ? 'desc' : 'asc';
} else {
_channelSortState.col = col;
_channelSortState.dir = col === 'name' || col === 'hash' ? 'asc' : 'desc';
}
saveChannelSort(_channelSortState);
updateChannelTable();
});
}
}
var CHANNEL_TIMELINE_MAX_SERIES = 8;
function renderChannelTimeline(data) {
if (!data.length) return '
No data
';
var hours = []; var hourSet = {};
var channelList = []; var channelSet = {};
var lookup = {};
var channelVolume = {};
for (var i = 0; i < data.length; i++) {
var d = data[i];
if (!hourSet[d.hour]) { hourSet[d.hour] = 1; hours.push(d.hour); }
if (!channelSet[d.channel]) { channelSet[d.channel] = 1; channelList.push(d.channel); }
lookup[d.hour + '|' + d.channel] = d.count;
channelVolume[d.channel] = (channelVolume[d.channel] || 0) + d.count;
}
hours.sort();
// Sort channels by total volume descending, cap to top N
channelList.sort(function(a, b) { return channelVolume[b] - channelVolume[a]; });
var hiddenCount = Math.max(0, channelList.length - CHANNEL_TIMELINE_MAX_SERIES);
var visibleChannels = channelList.slice(0, CHANNEL_TIMELINE_MAX_SERIES);
var maxCount = 1;
for (var vi = 0; vi < visibleChannels.length; vi++) {
for (var hi2 = 0; hi2 < hours.length; hi2++) {
var c = lookup[hours[hi2] + '|' + visibleChannels[vi]] || 0;
if (c > maxCount) maxCount = c;
}
}
var colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b'];
var w = 600, h = 180, pad = 35;
var xScale = (w - pad * 2) / Math.max(hours.length - 1, 1);
var yScale = (h - pad * 2) / maxCount;
var svg = 'Channel message activity over time';
for (var ci = 0; ci < visibleChannels.length; ci++) {
var pts = [];
for (var hi = 0; hi < hours.length; hi++) {
var count = lookup[hours[hi] + '|' + visibleChannels[ci]] || 0;
var x = pad + hi * xScale;
var y = h - pad - count * yScale;
pts.push(x + ',' + y);
}
svg += '';
}
var step = Math.max(1, Math.floor(hours.length / 6));
for (var li = 0; li < hours.length; li += step) {
var lx = pad + li * xScale;
svg += '' + hours[li].slice(11) + 'h';
}
svg += '';
var legendParts = [];
for (var lci = 0; lci < visibleChannels.length; lci++) {
legendParts.push('' + esc(visibleChannels[lci]) + '');
}
if (hiddenCount > 0) {
legendParts.push('+' + hiddenCount + ' more');
}
svg += '
' + legendParts.join('') + '
';
return svg;
}
function renderTopSenders(senders) {
if (!senders.length) return '
No decrypted messages
';
const max = senders[0].count;
let html = '
';
senders.slice(0, 10).forEach(s => {
html += `
${esc(s.name)}
${s.count} msgs
`;
});
return html + '
';
}
// ===================== HASH SIZES (original) =====================
function renderHashSizes(el, data) {
const d = data.distribution;
const total = data.total;
const pct = (n) => total ? (n / total * 100).toFixed(1) : '0';
const maxCount = Math.max(d[1] || 0, d[2] || 0, d[3] || 0, 1);
el.innerHTML = `
Nodes advertising with 2+ byte hash paths. ' +
'Confirmed = seen advertising with multi-byte hash. ' +
'Suspected = prefix appeared in a multi-byte path. ' +
'Unknown = no multi-byte evidence yet.
' +
'
' +
'
' +
'' +
'' +
'' +
'' +
'
' +
'
' +
'
' + buildTableContent(rows, 'all') + '
' +
'
';
// Use setTimeout for event delegation on the stable section container
setTimeout(function() {
var section = document.getElementById('mbAdoptersSection');
if (!section) return;
var currentFilter = 'all';
section.addEventListener('click', function handler(e) {
var btn = e.target.closest('[data-mb-filter]');
if (btn) {
currentFilter = btn.dataset.mbFilter;
// Update active state on buttons (no DOM replacement needed)
var buttons = section.querySelectorAll('[data-mb-filter]');
buttons.forEach(function(b) { b.classList.toggle('active', b.dataset.mbFilter === currentFilter); });
// Replace only the table content, not the whole section
var wrap = section.querySelector('#mbAdoptersTableWrap');
if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter);
return;
}
var th = e.target.closest('[data-sort]');
if (th) {
var tbody = section.querySelector('tbody');
if (!tbody) return;
var sortRows = Array.from(tbody.querySelectorAll('tr'));
var col = th.dataset.sort;
var colIdx = { name: 0, status: 1, hashSize: 2, packets: 3, lastSeen: 4 };
var statusWeight = { 'confirmed': 0, 'suspected': 1, 'unknown': 2 };
sortRows.sort(function(a, b) {
var va = a.children[colIdx[col]] ? a.children[colIdx[col]].textContent.trim() : '';
var vb = b.children[colIdx[col]] ? b.children[colIdx[col]].textContent.trim() : '';
if (col === 'status') {
va = statusWeight[va.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[va.toLowerCase().split(' ').pop()] : 2;
vb = statusWeight[vb.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[vb.toLowerCase().split(' ').pop()] : 2;
}
if (col === 'hashSize' || col === 'packets') { va = parseInt(va) || 0; vb = parseInt(vb) || 0; }
if (va < vb) return -1;
if (va > vb) return 1;
return 0;
});
sortRows.forEach(function(r) { tbody.appendChild(r); });
}
});
}, 100);
return html;
}
// Legacy alias for tests — delegates to renderMultiByteAdopters with empty nodes
function renderMultiByteCapability(caps) {
if (!caps.length) return '';
// Convert caps to adopter-style rows for backward compat
var fakeNodes = caps.map(function(c) {
return { name: c.name, pubkey: c.pubkey, role: c.role, hashSize: c.maxHashSize, packets: 0, lastSeen: c.lastSeen };
});
return renderMultiByteAdopters(fakeNodes, caps);
}
async function renderCollisionTab(el, data, collisionData) {
el.innerHTML = `
Collisions actually observed in packet traffic — among repeaters grouped by their configured hash size. For theoretical address conflicts that would occur if all repeaters used a given hash size, see the Prefix Tool tab.
Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a firmware bug where automatic adverts ignored the configured multibyte path setting, fixed in repeater v1.14.1. Companion nodes are excluded.
${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.
`;
}
}
// Repeaters and routing nodes no longer needed — collision data is server-computed
let currentBytes = 1;
function refreshHashViews(bytes) {
currentBytes = bytes;
hideMatrixTip();
// Update selector button states
document.querySelectorAll('.hash-byte-btn').forEach(b => {
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
});
// Update titles and description
const matrixTitle = document.getElementById('hashMatrixTitle');
const matrixDesc = document.getElementById('hashMatrixDesc');
const riskTitle = document.getElementById('collisionRiskTitle');
if (matrixTitle) matrixTitle.innerHTML = bytes === 3 ? ' Hash Usage Matrix' : ` ${bytes}-Byte Hash Usage Matrix`;
if (riskTitle) riskTitle.innerHTML = ` ${bytes}-Byte Collision Risk`;
if (matrixDesc) {
if (bytes === 1) matrixDesc.textContent = 'Cells include the first byte of all repeaters — including those using 2- or 3-byte prefixes — so this reflects real conflicts in the 1-byte hash space. Click a cell to see the nodes.';
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
}
renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes);
// Show collision risk section for all byte sizes
const riskCard = document.getElementById('collisionRiskSection');
if (riskCard) riskCard.style.display = '';
renderCollisionsFromServer(cData.by_size[String(bytes)], bytes);
}
// Wire up selector
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
});
refreshHashViews(1);
}
function renderHashTimeline(hourly) {
if (!hourly.length) return '
Not enough data
';
const w = 800, h = 180, pad = 35;
const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1);
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
let svg = `Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends`;
for (const size of [1, 2, 3]) {
const pts = hourly.map((d, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
const y = h - pad - ((d[size] || 0) / maxVal) * (h - pad * 2);
return `${x},${y}`;
}).join(' ');
if (hourly.some(d => d[size] > 0)) svg += ``;
}
const step = Math.max(1, Math.floor(hourly.length / 8));
for (let i = 0; i < hourly.length; i += step) {
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
svg += `${hourly[i].hour.slice(11)}h`;
}
svg += '';
svg += `
1-byte2-byte3-byte
`;
return svg;
}
// Shared hover tooltip for hash matrix cells.
// Called once per container — reads content from data-tip on each
.
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
let _matrixTip = null;
function getMatrixTip() {
if (!_matrixTip) {
_matrixTip = document.createElement('div');
_matrixTip.className = 'hash-matrix-tooltip';
_matrixTip.style.display = 'none';
document.body.appendChild(_matrixTip);
}
return _matrixTip;
}
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
// SECURITY (ANL-1, PR #1539 round 2): rebuild tooltip body via DOM APIs.
// The previous round-1 fix used `tip.textContent = td.dataset.tip`, which
// defeated the mutation-XSS but also broke the rendered tooltip — the
// payload had been a structured HTML string from hashTooltipHtml(), so
// users saw literal `
…
` text. The correct fix is
// to stop carrying HTML through the dataset round-trip entirely: each
// tooltip field rides as its own data-tip-* attribute (entity-decoded by
// the browser on read = plain text), and we materialize the three styled
// child
s via createElement + textContent. No innerHTML on any
// node-controlled field path.
function buildMatrixTipChildren(tip, td) {
while (tip.firstChild) tip.removeChild(tip.firstChild);
const ds = td.dataset || {};
if (ds.tipHex) {
const h = document.createElement('div');
h.className = 'hash-matrix-tooltip-hex';
h.textContent = ds.tipHex;
tip.appendChild(h);
}
if (ds.tipStatus) {
const s = document.createElement('div');
s.className = 'hash-matrix-tooltip-status';
s.textContent = ds.tipStatus;
tip.appendChild(s);
}
if (ds.tipLines) {
const wrap = document.createElement('div');
wrap.className = 'hash-matrix-tooltip-nodes';
const lines = ds.tipLines.split('\u001f');
for (let i = 0; i < lines.length; i++) {
const row = document.createElement('div');
row.style.fontSize = '11px';
row.textContent = lines[i];
wrap.appendChild(row);
}
tip.appendChild(wrap);
}
}
function initMatrixTooltip(el) {
if (el._matrixTipInit) return;
el._matrixTipInit = true;
el.addEventListener('mouseover', e => {
const td = e.target.closest('td[data-tip-hex]');
if (!td) return;
const tip = getMatrixTip();
buildMatrixTipChildren(tip, td);
tip.style.display = 'block';
});
el.addEventListener('mousemove', e => {
if (!_matrixTip || _matrixTip.style.display === 'none') return;
const x = e.clientX + 14, y = e.clientY + 14;
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
});
el.addEventListener('mouseout', e => {
if (e.target.closest('td[data-tip-hex]') && !e.relatedTarget?.closest('td[data-tip-hex]')) hideMatrixTip();
});
el.addEventListener('mouseleave', hideMatrixTip);
}
// --- Shared helpers for hash matrix rendering ---
function hashStatCardsHtml(totalNodes, usingCount, sizeLabel, spaceSize, usedCount, collisionCount) {
const pct = spaceSize > 0 && usedCount > 0 ? ((usedCount / spaceSize) * 100) : 0;
const pctStr = spaceSize > 65536 ? pct.toFixed(6) : spaceSize > 256 ? pct.toFixed(3) : pct.toFixed(1);
const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString();
return `
Nodes tracked
${totalNodes.toLocaleString()}
Using ${sizeLabel} ID
${usingCount.toLocaleString()}
Prefix space used
${pctStr}%
${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible
0 ? 'onclick="document.getElementById(\'collisionRiskSection\')?.scrollIntoView({behavior:\'smooth\',block:\'start\'})"' : ''} ${collisionCount > 0 ? 'title="Click to see collision details"' : ''}>
Prefix collisions
${collisionCount}${collisionCount > 0 ? ' ' : ''}
`;
}
function hashMatrixGridHtml(nibbles, cellSize, headerSize, cellDataFn) {
let html = `
`;
html += `
`;
for (const n of nibbles) html += `
${n}
`;
html += '
';
for (let hi = 0; hi < 16; hi++) {
html += `
${nibbles[hi]}
`;
for (let lo = 0; lo < 16; lo++) {
html += cellDataFn(nibbles[hi] + nibbles[lo], cellSize);
}
html += '
';
}
html += '
';
return html;
}
function hashMatrixLegendHtml(labels) {
return `
${labels.map(l => ` ${l.text}`).join('\n')}
`;
}
// --- Shared cell classification for hash matrix ---
function classifyHashCell(count, isConfirmedCollision, isPossibleConflict) {
if (count === 0) return { cls: 'hash-cell-empty', bg: '' };
if (!isConfirmedCollision && !isPossibleConflict) return { cls: 'hash-cell-taken', bg: '' };
if (isPossibleConflict) return { cls: 'hash-cell-possible', bg: '' };
const t = Math.min((count - 2) / 4, 1);
// #1668-M4 r1: gradient endpoints darkened from orange-500→red-500 (3.11:1/3.96:1 vs #fff)
// to orange-800→red-800 (7.31:1→8.31:1 vs #fff) — passes WCAG AA across the whole range.
// Encoding stays distinguishable from green-700 (taken) and yellow-800 (possible).
const cr = Math.round(154 + (-1) * t); // 154 → 153
const cg = Math.round(52 + (-25) * t); // 52 → 27
const cb = Math.round(18 + 9 * t); // 18 → 27
return { cls: 'hash-cell-collision', bg: `background:rgb(${cr},${cg},${cb});` };
}
// hashCellTd — emits a hash-matrix
. Tooltip data rides as separate
// data-tip-* attributes (plain text per field); buildMatrixTipChildren()
// materializes the styled DOM on mouseover. NEVER carry HTML through
// data-tip-* — see ANL-1 (PR #1539).
function hashCellTd(hex, cellSize, cls, bg, count, tipSpec, fontWeight) {
// tipSpec: { hex: string, status?: string, lines?: string[] }
const spec = tipSpec || {};
const hexAttr = ' data-tip-hex="' + esc(spec.hex || '') + '"';
const statusAttr = spec.status
? ' data-tip-status="' + esc(spec.status) + '"'
: '';
// Join row lines with U+001F (Unit Separator) — a control char that
// never appears in node names, so we can split safely on the read side.
const linesAttr = (spec.lines && spec.lines.length)
? ' data-tip-lines="' + esc(spec.lines.join('\u001f')) + '"'
: '';
return `
${hex}
`;
}
// hashTooltipSpec — returns a plain-data tooltip descriptor consumed by
// hashCellTd and rendered by buildMatrixTipChildren. Plain text only;
// no HTML, no markup. `lines` is an array of pre-formatted row strings.
function hashTooltipSpec(hexLabel, statusText, lines) {
const spec = { hex: hexLabel, status: statusText };
if (lines && lines.length) spec.lines = lines;
return spec;
}
function renderHashMatrixPanel(el, statCardsHtml, cellRendererFn, detailMaxWidth, legendLabels, clickHandlerFn) {
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
let html = statCardsHtml;
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, cellRendererFn);
html += `
`;
html += hashMatrixLegendHtml(legendLabels);
el.innerHTML = html;
initMatrixTooltip(el);
// #1473 — Grey out cells whose first byte the MeshCore firmware keygen
// routine avoids (pub_key[0] in {0x00, 0xFF}). This is a keygen
// CONVENTION, not a protocol-level rejection — see firmware
// examples/simple_repeater/main.cpp:83 (HEAD 8ede7641). Must run BEFORE
// we wire click handlers so .hash-active is stripped first.
if (typeof PrefixReserved !== 'undefined' && PrefixReserved && typeof PrefixReserved.markReservedCells === 'function') {
PrefixReserved.markReservedCells(el);
}
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
clickHandlerFn(td);
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
}
function renderHashMatrixFromServer(sizeData, bytes) {
const el = document.getElementById('hashMatrix');
if (!sizeData) { el.innerHTML = '
No data
'; return; }
const stats = sizeData.stats || {};
const totalNodes = stats.total_nodes || 0;
// 3-byte: show a summary panel instead of a matrix
if (bytes === 3) {
el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) +
`
The 3-byte prefix space (16.7M values) is too large to visualize as a grid.${(stats.collision_count || 0) > 0 ? ' See collision details below.' : ''}
` +
`
ℹ️ This tab only counts collisions among repeaters configured for this hash size. The Prefix Tool checks all repeaters regardless of configured hash size.
Local <${t50}: true prefix collision, same mesh area
Regional ${t50}–${t200}: edge of LoRa range, possible atmospheric propagation
Distant >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
`;
}
async function renderSubpaths(el) {
el.innerHTML = '
Analyzing route patterns…
';
try {
const rq = RegionFilter.regionQueryString();
// Issue #1217: thread the Time window picker into the Route Patterns
// request so the chart actually reflects the user's selection.
const twEl = document.getElementById('analyticsTimeWindow');
const twVal = twEl ? twEl.value : '';
const tws = twVal ? '&window=' + encodeURIComponent(twVal) : '';
const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq + tws, { ttl: CLIENT_TTL.analyticsRF });
const [d2, d3, d4, d5] = bulk.results;
function renderTable(data, title) {
if (!data.subpaths.length) return `
${title}
No data
`;
// #1633 — when "Hide 1-byte path hops" is ON, filter route patterns
// whose underlying rawHops contain any 1-byte hex token. We filter
// INPUT (not just CSS-hide) so the displayed % and ordering reflect
// the surviving population.
const _hide1 = !!(typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops());
const _hop1 = function (h) { return String(h || '').length === 2; };
const subpaths = _hide1
? data.subpaths.filter(function (s) {
var rh = s.rawHops || [];
for (var k = 0; k < rh.length; k++) if (_hop1(rh[k])) return false;
return true;
})
: data.subpaths;
if (!subpaths.length) return `
${title}
No data (all matching routes contained 1-byte hops — toggle off in customizer to see)
`;
}
}
// ===================== MY REPEATERS (favorites monitoring dashboard) =====================
// An at-a-glance monitor over the repeaters the operator has starred
// (meshcore-favorites). Pure frontend aggregation over existing endpoints:
// /api/nodes relay activity + traffic/bridge scores
// /api/nodes/clock-skew per-node "is the clock OK" severity
// /api/analytics/neighbor-graph affinity edges, filtered to favorites
// ("throughput between my repeaters")
function _mrSummaryCard(value, label, color) {
return `
${esc(String(value))}
${esc(label)}
`;
}
// Status is carried by colour AND text — never colour alone — so it is
// legible to color-blind users and screen readers.
const MR_STATUS_META = {
active: { color: 'var(--status-green)', label: 'Active' },
degraded: { color: 'var(--status-yellow)', label: 'Degraded' },
silent: { color: 'var(--status-red)', label: 'Silent' },
unknown: { color: 'var(--text-muted)', label: 'Unknown' },
};
function _mrStatusCell(status) {
// Fall back to an explicit "Unknown" rather than silently mislabelling an
// unexpected status as "Silent".
const m = MR_STATUS_META[status] || MR_STATUS_META.unknown;
return `${m.label}`;
}
function _mrEmptyState() {
return `
No favorite repeaters yet
Star a repeater (the on the Nodes page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.
';
try {
const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString();
// When a region/area filter is active the node fetch is scoped, so a
// starred repeater outside that scope won't come back. Surface that count
// instead of silently dropping it from the watch-list (#1761 MAJOR).
const filterActive = rq.length > 0;
// Fetch the three sources ONCE; star toggles re-paint from this cache
// (paint() below) instead of re-hitting the network. Trade-off: this
// pages the full node list (up to ~10k) to keep only the starred few —
// fine for a handful of favorites and amortized by CLIENT_TTL.nodeList
// across tabs. If favorite counts ever grow large, switch to per-node
// Promise.all(favs.map(pk => api('/nodes/' + pk))).
const [nodesResp, clockData, graphData] = await Promise.all([
fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/clock-skew', { ttl: CLIENT_TTL.analyticsRF }).catch(() => []),
api('/analytics/neighbor-graph?min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ nodes: [], edges: [] })),
]);
const allNodes = nodesResp.nodes || nodesResp;
const clockByPk = {};
(Array.isArray(clockData) ? clockData : []).forEach(c => { if (c && c.pubkey) clockByPk[c.pubkey] = c; });
const now = Date.now();
function ageOf(n) {
const ms = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() : 0;
return ms ? now - ms : Infinity;
}
function statusOf(n) {
const th = (typeof getHealthThresholds === 'function') ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
const age = ageOf(n);
return age < th.degradedMs ? 'active' : age < th.silentMs ? 'degraded' : 'silent';
}
const pct = v => (v != null ? (v * 100).toFixed(1) + '%' : '—');
// #1456: prefer traffic_share_score, fall back to usefulness_score.
const trafficOf = n => (n.traffic_share_score != null ? n.traffic_share_score : (n.usefulness_score != null ? n.usefulness_score : null));
function roleBadge(role) {
// Route the unknown-role fallback through ROLE_COLORS.unknown (backed by
// --mc-role-unknown / the shared Wong palette) instead of a hard-coded
// literal, so every swatch color flows through the CSS-variable system
// (#1761 nit). ROLE_COLORS always resolves .unknown.
const rc = window.ROLE_COLORS || {};
const c = rc[role] || rc.unknown || 'var(--role-unknown, #6b7280)';
const style = (window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:' + c + ';color:#fff');
return `${esc(role)}`;
}
// Re-renders from the cached fetch using the LIVE favorites set, so an
// un-star drops the row with no refetch. Re-binds its stars after each
// paint (the innerHTML reset clears listeners).
function paint() {
const favs = new Set(haveFavs());
if (!favs.size) { el.innerHTML = _mrEmptyState(); return; }
const myNodes = allNodes.filter(n => favs.has(n.public_key) && (n.role === 'repeater' || n.role === 'room'));
const myKeys = new Set(myNodes.map(n => n.public_key));
// Favorites the scoped fetch didn't return — with a region/area filter
// active these are (most likely) outside the current scope. Surface the
// count so a hidden favorite isn't silently missing (#1761 MAJOR).
const allByPk = new Set(allNodes.map(n => n.public_key));
// Favorites absent from the scoped fetch: usually outside the active
// region/area filter, but the set also covers deleted/expired stars
// and role-filtered clients — so the notice stays causally neutral
// ("not shown with the active filter") rather than claiming a cause.
const hiddenByFilter = [...favs].filter(pk => !allByPk.has(pk)).length;
const filterNotice = (filterActive && hiddenByFilter > 0)
? `
${hiddenByFilter} favorite${hiddenByFilter === 1 ? '' : 's'} not shown with the active region/area filter.
No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.
`;
} else {
affinityHtml = `
Link
Affinity
Packets
Avg SNR
${myEdges.map(e => {
// The affinity graph is undirected — the server marks every edge
// bidirectional — so the link glyph is always a double arrow.
const arrow = '↔';
const snr = (e.avg_snr != null) ? e.avg_snr.toFixed(1) + ' dB' : '—';
return `
None of your favorites are repeaters/rooms in this view.
'}
Links Between My Repeaters
Affinity sub-graph filtered to your favorites — how much traffic flows directly between them.
${affinityHtml}
`;
// Live un-star: re-paint from cache (no refetch) when a star toggles.
if (typeof bindFavStars === 'function') bindFavStars(el, paint);
}
paint();
} catch (e) {
el.innerHTML = `
Failed to load your repeaters: ${esc(e.message)}
`;
}
}
// === MY REPEATERS BLOCK END (test harness slices the renderer block to here) ===
async function renderDistanceTab(el) {
try {
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
const s = data.summary;
let html = `
${cfg.toLocaleString()}
of ${totalNodes.toLocaleString()} repeaters configured
${opLine}
${opToggle}
Theoretical: ${stats[b].usedPrefixes.toLocaleString()} unique ${b}-byte slice${stats[b].usedPrefixes !== 1 ? 's' : ''}
across all repeater pubkeys (of ${spaceSizes[b].toLocaleString()} possible)${theoC > 0 ? ` — ${theoC} would-collide if every repeater used ${b}-byte` : ''}
${theoToggle}
`;
}).join('')}
ℹ️ Theoretical vs observed: These are theoretical address conflicts that would occur IF all repeaters used this hash size (would-collide-if-used). For collisions actually observed in packet traffic, see the Hash Issues tab.
Recommendation: ${rec} prefixes — ${recDetail}
Hash size is configured per-node in firmware. Changing requires reflashing.
ℹ️ About these numbers: The primary count is how many repeaters are configured for each hash size (their advertised path hash byte length), matching the
Hash Stats tab. Address conflicts (would-collide-if-used) count colliding slices among repeaters configured for the same hash size — same definition the
Hash Issues tab uses, except Hash Issues counts collisions actually observed in packet traffic rather than theoretical. The theoretical line shows the math fact: how many distinct slices appear when every repeater pubkey is truncated to N bytes, regardless of configured hash size.
Check a Prefix
Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix — or paste a full public key.
Generate Available Prefix
Find a prefix with zero current collisions.
0x00 and 0xFF excluded as a first byte — the MeshCore firmware keygen routine re-rolls identities whose pub_key[0] is 00 or FF, so by convention you should not see those prefixes on real nodes (see
simple_repeater/main.cpp:83).
Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.
';
return;
}
const isFullKey = input.length >= 8;
const tiers = isFullKey
? [{ b: 1, prefix: input.slice(0, 2) }, { b: 2, prefix: input.slice(0, 4) }, { b: 3, prefix: input.slice(0, 6) }]
: [{ b: input.length / 2, prefix: input }];
let html = '';
// #1473 — Warn when the user pastes a prefix or full pubkey whose
// first byte is one the MeshCore firmware keygen routine avoids
// (pub_key[0] in {0x00, 0xFF}). Firmware keygen CONVENTION, not a
// protocol-level rejection — see simple_repeater/main.cpp:83.
if (typeof PrefixReserved !== 'undefined' && PrefixReserved &&
PrefixReserved.isReservedPrefix(input)) {
html += `
Firmware avoids this first byte
${input.slice(0,2)} as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose pub_key[0] is 00 or FF). You generally shouldn't see this on real nodes.
`;
}
if (isFullKey) {
const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input);
html += `
Derived prefixes: ${input.slice(0,2)} / ${input.slice(0,4)} / ${input.slice(0,6)}${!inNetwork ? ' — this node is not yet in the network' : ''}
Failed to load clock health data: ' + esc(String(err)) + '
';
}
}
// ===================== SCOPES =====================
async function renderScopesTab(el) {
var winKey = 'scopes_window';
var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h';
// Fix 5: write static frame only once
if (!el.querySelector('#scopes-cards')) {
el.innerHTML =
'
Scope Statistics
' +
'
' +
'Denominator is all observed transmissions. Only TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) routes carry a scope; FLOOD (1) and DIRECT (2) are inherently unscoped per MeshCore protocol.' +
'
' +
'' +
'' +
'' +
'' +
'';
// Attach window-button click listeners (once)
el.querySelectorAll('[data-win]').forEach(function(btn) {
btn.addEventListener('click', function() {
selectedWindow = btn.dataset.win;
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow);
el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); });
load(selectedWindow);
});
});
}
function pct(n, total) {
if (!total) return '—';
return (n / total * 100).toFixed(1) + '%';
}
async function load(w) {
var loadingEl = document.getElementById('scopes-loading');
if (loadingEl) loadingEl.style.display = '';
try {
// Fix 4: use api() instead of raw fetch()
var data = await api('/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
if (loadingEl) loadingEl.style.display = 'none';
if (data.error) {
var cardsEl2 = document.getElementById('scopes-cards');
if (cardsEl2) cardsEl2.innerHTML = '
' + esc(data.error) + '
';
return;
}
updateData(data, w);
} catch (err) {
if (loadingEl) loadingEl.style.display = 'none';
var cardsEl3 = document.getElementById('scopes-cards');
if (cardsEl3) cardsEl3.innerHTML = '
Failed to load scope stats: ' + esc(String(err)) + '
';
}
}
function updateData(d, w) {
var s = d.summary;
// #1838: denominator = transport-carrying transmissions (route_type 0,3).
// Unscoped now includes non-transport routes (1,2) which are inherently
// unscoped by MeshCore protocol, so unscoped can exceed transportTotal.
var total = s.transportTotal || 0;
var overall = (s.scoped || 0) + (s.unscoped || 0);
// Summary cards
var cardsEl = document.getElementById('scopes-cards');
if (cardsEl) {
cardsEl.innerHTML = [
{ label: 'Transport Total', value: total.toLocaleString(), note: 'routes 0,3 (carry scope)' },
{ label: 'Scoped', value: s.scoped.toLocaleString(), note: pct(s.scoped, overall) + ' of all traffic' },
{ label: 'Unscoped', value: s.unscoped.toLocaleString(), note: pct(s.unscoped, overall) + ' of all traffic' },
{ label: 'Unknown Scope', value: s.unknownScope.toLocaleString(), note: pct(s.unknownScope, s.scoped) + ' of scoped' },
].map(function(c) {
return '
' + c.value + '
' +
'
' + c.label + '
' +
(c.note ? '
' + c.note + '
' : '') +
'
';
}).join('');
}
// Channel-messages-only breakdown: same scoped/unscoped/unknown
// question as the cards above, but restricted to payload_type=5
// (channel chat) — most channel traffic is plain FLOOD, so this can
// read very differently from the all-traffic numbers above.
var chanEl = document.getElementById('scopes-channel-messages');
if (chanEl) {
var cm = d.channelMessages;
if (cm && cm.totalMessages > 0) {
var cmOverall = cm.scoped + cm.unscoped;
chanEl.innerHTML =
'
Channel Messages
' +
'
Same scoped/unscoped/unknown breakdown, restricted to channel chat messages only.
Insufficient data points to render chart — wait for more observations in this window.
';
}
chartEl.innerHTML = chartHtml;
}
// Region utilization: how much of the configured hashRegions list
// has never actually matched anything — all-time (not window-scoped),
// so it doesn't fluctuate with the 1h/24h/7d selector above. Only
// shown when the server config has hashRegions configured.
var utilEl = document.getElementById('scopes-utilization');
if (utilEl) {
var configured = d.configuredRegions || 0;
if (configured > 0) {
var unused = d.unusedRegions || [];
var usedCount = configured - unused.length;
var unusedPct = (unused.length / configured * 100).toFixed(1);
var listHtml = unused.map(function(name) { return esc(name); }).join(', ');
utilEl.innerHTML =
'
Region Utilization
' +
'
' +
'All-time, not limited to the window above — has this configured region ever matched a message still in retention?' +
'
' +
'
' +
'' + usedCount.toLocaleString() + ' of ' + configured.toLocaleString() + ' configured regions have matched at least once' +
(unused.length > 0 ? ' — ' + unused.length.toLocaleString() + ' (' + unusedPct + '%) have never matched anything.' : '.') +
'
'
: '');
} else {
utilEl.innerHTML = '';
}
}
// Renders a " -> [node links]" breakdown into a container.
// Shared by "Repeaters by Region" (transported_scopes — who relayed
// traffic for this region) and "Nodes Running This Region"
// (default_scope — who is actually configured with it). Both are
// all-time, not limited to the window selector above.
function renderRegionNodeGroups(elId, title, description, groups, unitLabel) {
var el = document.getElementById(elId);
if (!el) return;
if (!groups || !groups.length) { el.innerHTML = ''; return; }
var rows = groups.map(function(g) {
var links = g.repeaters.map(function(rp) {
return '' + esc(rp.name) + '';
}).join(', ');
return '' +
'' + esc(g.region) + ' — ' + g.count.toLocaleString() + ' ' + unitLabel + (g.count === 1 ? '' : 's') + '' +
'
' + links + '
' +
'';
}).join('');
el.innerHTML =
'
' + esc(title) + '
' +
'
' + esc(description) + '
' +
rows;
}
renderRegionNodeGroups('scopes-repeaters', 'Repeaters by Region',
'All-time, not limited to the window above — which repeaters have relayed traffic carrying each region scope. A region carried by only 1 repeater is a single point of failure for that area.',
d.repeatersByRegion, 'repeater');
// Bridge repeaters: RepeatersByRegion inverted — repeaters relaying
// for MORE than one region are the mesh's literal backbone nodes.
var bridgeEl = document.getElementById('scopes-bridges');
if (bridgeEl) {
var bridges = d.bridgeRepeaters || [];
if (bridges.length > 0) {
var bridgeRows = bridges.map(function(b) {
var regionList = b.regions.map(function(r) { return '' + esc(r) + ''; }).join(', ');
return '
' +
'All-time — repeaters that have relayed traffic for more than one region. These connect otherwise-separate regional communities; losing one can split the mesh\'s regional coverage.' +
'
' +
'
' +
'
Repeater
# Regions
Regions
' +
'' + bridgeRows + '' +
'
';
} else {
bridgeEl.innerHTML = '';
}
}
renderRegionNodeGroups('scopes-origin-nodes', 'Nodes Running This Region',
'All-time — nodes whose OWN configured scope is this region (not just relaying it for others). This is a much smaller, more specific set than "Repeaters by Region" above.',
d.originatingNodesByRegion, 'node');
}
load(selectedWindow);
// Fix 6: auto-refresh every 60s
_stopScopesRefresh();
_scopesRefreshTimer = setInterval(function() {
if (_currentTab !== 'scopes') { _stopScopesRefresh(); return; }
var cur = document.getElementById('analyticsContent');
if (!cur) { _stopScopesRefresh(); return; }
load(selectedWindow);
}, 60000);
}
// #1085 — Roles tab (folded in from former /#/roles page).
// Renders distribution of node roles + per-role clock-skew posture.
// Auto-refreshes every 60s while the Roles tab is active (matches the
// behavior of the former standalone roles-page.js).
async function renderRolesTab(el) {
el.innerHTML = '
Loading roles…
';
await _renderRolesTabBody(el);
// (Re)start the 60s auto-refresh.
_stopRolesRefresh();
_rolesRefreshTimer = setInterval(function () {
// Bail if the user navigated away from the Roles tab.
if (_currentTab !== 'roles') { _stopRolesRefresh(); return; }
var cur = document.getElementById('analyticsContent');
if (!cur) { _stopRolesRefresh(); return; }
_renderRolesTabBody(cur);
}, 60000);
}
async function _renderRolesTabBody(el) {
try {
var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF });
var roles = (data && data.roles) || [];
var total = (data && data.totalNodes) || 0;
if (!roles.length) {
el.innerHTML = '