Files
meshcore-analyzer/public/nodes.js
T
n30nexandn30nex c211ef7643 feat(nodes): show relay activity from certain forwarding evidence (#2336)
Fixes #2331 with the additive API option.

The Last Relayed row shows green **actively relaying** only when
`last_certain_relay` falls within the existing
`healthThresholds.relayActiveHours` window. Missing, invalid, future or
expired evidence adds no idle/alive claim. Existing `relay_active`,
attributed timestamps and counts retain their meanings.

The optional node list/detail field uses the existing certain-relay
path: an eligible observed frame, matching hop, unique prefix, no
retired namesake and existing signature checks. Originating an advert
does not count. Forwarding another node's advert can count, even with
zero non-advert attributions. Adverts with an unknown origin cannot
establish forwarding.

Evidence uses transmission `first_seen`, not late observation receipt
time, and follows node-membership eviction. Snapshots take one read lock
over requested keys, O(page size), with no packet scan or new database
query. Work per qualifying hop stays bounded. No new dependency or
setting; configuration documentation and OpenAPI explain the
distinction.

The test-first history includes assertion-failing RED commits and GREEN
fixes. The initial RED's nil-return compile stub is explicitly permitted
by `docs/agents/TDD.md`. Local native server/ingestor suites, focused
race checks, 809 frontend helper assertions and 70 browser cases pass.
Browser coverage includes both themes and full/side/tablet detail views.

[Review regression
RED](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40421464618/job/121916723339)
proves both malformed-origin failures. All three independent review
roles pass at `d1fe79cb`. [Final
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40422655728)
passes on that exact commit, including all three browser shards and the
two-architecture image check.

---------

Co-authored-by: n30nex <6686206+n30nex@users.noreply.github.com>
2026-10-29 18:33:59 +01:00

2206 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === CoreScope — nodes.js === */
'use strict';
(function () {
let nodes = [];
const PAYLOAD_TYPES = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'};
function syncClaimedToFavorites() {
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const favs = getFavorites();
let changed = false;
myNodes.forEach(mn => {
if (!favs.includes(mn.pubkey)) { favs.push(mn.pubkey); changed = true; }
});
if (changed) localStorage.setItem('meshcore-favorites', JSON.stringify(favs));
}
let counts = {};
let selectedKey = null;
let activeTab = 'all';
let search = '';
// Sort state: column + direction, persisted to localStorage
// Managed by TableSort utility (public/table-sort.js) when DOM is available,
// falls back to simple object for unit testing
var _nodesTableSortCtrl = null;
// TODO(M5): remove fallback when tests use DOM sandbox
var _fallbackSortState = null; // used when TableSort controller not initialized (tests)
function _getSortState() {
if (_nodesTableSortCtrl) return _nodesTableSortCtrl.getState();
if (_fallbackSortState) return _fallbackSortState;
try {
var saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort'));
if (saved && saved.column && saved.direction) return saved;
} catch (e) { /* ignore */ }
return { column: 'last_seen', direction: 'desc' };
}
function sortNodes(arr) {
var sortState = _getSortState();
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
return arr.sort(function (a, b) {
let va, vb;
if (col === 'name') {
va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase();
if (!a.name && b.name) return 1;
if (a.name && !b.name) return -1;
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'public_key') {
va = a.public_key || ''; vb = b.public_key || '';
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'role') {
va = (a.role || '').toLowerCase(); vb = (b.role || '').toLowerCase();
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'default_scope') {
va = (a.default_scope || '').toLowerCase(); vb = (b.default_scope || '').toLowerCase();
if (!a.default_scope && b.default_scope) return 1;
if (a.default_scope && !b.default_scope) return -1;
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'last_seen') {
va = a.last_heard ? new Date(a.last_heard).getTime() : a.last_seen ? new Date(a.last_seen).getTime() : 0;
vb = b.last_heard ? new Date(b.last_heard).getTime() : b.last_seen ? new Date(b.last_seen).getTime() : 0;
return (va - vb) * dir;
} else if (col === 'advert_count') {
va = a.advert_count || 0; vb = b.advert_count || 0;
return (va - vb) * dir;
} else if (col === 'first_seen') {
// Issue #1166: sort by node first_seen. Empty cells always sort
// LAST (regardless of direction) so unknown timestamps don't
// clutter the top of the table.
var aHas = a.first_seen ? 1 : 0;
var bHas = b.first_seen ? 1 : 0;
if (aHas !== bHas) return bHas - aHas; // dated rows before empties
if (!aHas) return 0;
va = new Date(a.first_seen).getTime();
vb = new Date(b.first_seen).getTime();
return (va - vb) * dir;
}
return 0;
});
}
let lastHeard = localStorage.getItem('meshcore-nodes-last-heard') || '';
let statusFilter = localStorage.getItem('meshcore-nodes-status-filter') || 'all';
// #1845: "silent longer than N", the inverse of the Last Heard filter. Empty
// means off. Ages come from getEffectiveHeardMs, whose last_heard includes
// certain relays (#2260), so a repeater that forwards traffic is never
// counted as silent just because its ADVERT is old.
let silentFor = localStorage.getItem('meshcore-nodes-silent-for') || '';
const SILENT_MS = { '1d': 86400000, '3d': 259200000, '7d': 604800000, '14d': 1209600000, '30d': 2592000000 };
// #1268 M3: '' or 'differs' (LoRa settings differ from nearby same-band nodes).
let loraFilter = '';
let _loraDiffCount = 0;
let _silentCounts = null;
let wsHandler = null;
let detailMap = null;
let detailMapResizeTimer = null;
function removeDetailMap(owner) {
// Async responses may still hold a detached view while another map is active.
if (owner && detailMap && !owner.contains(detailMap.getContainer())) return;
clearTimeout(detailMapResizeTimer);
detailMapResizeTimer = null;
if (detailMap) { detailMap.remove(); detailMap = null; }
}
function resizeDetailMapAfterLayout() {
const map = detailMap;
detailMapResizeTimer = setTimeout(() => {
detailMapResizeTimer = null;
map.invalidateSize();
}, 100);
}
// #1461 followup: node-detail inset map tile layer that honors the
// customizer dark-tile-provider pick (#1420/#1430). Falls back to
// window.getTileUrl() output if the registry isn't loaded. Also applies
// the provider's invert CSS filter to the tile pane when needed.
function _applyTilesToNodeMap(map) {
if (!map) return;
var tileUrl = (window.getTileUrl && window.getTileUrl()) || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
var provider = window.getActiveTileProvider && window.getActiveTileProvider();
var attribution = (provider && provider.attribution) || '© OpenStreetMap contributors';
var layer = L.tileLayer(tileUrl, { maxZoom: 18, attribution: attribution }).addTo(map);
// Esri 2-layer provider: add the labels reference overlay too
if (provider && provider.refUrl) {
try { L.tileLayer(provider.refUrl, { maxZoom: 18 }).addTo(map); } catch (_e) {}
}
// Apply invert CSS filter to the tile pane if the provider needs it
try {
var pane = map.getPane && map.getPane('tilePane');
if (pane) pane.style.filter = (provider && provider.invertFilter) ? provider.invertFilter : '';
} catch (_e) {}
return layer;
}
// Exposed so the node-reach link-map (node-reach-map.js) reuses the user's
// configured tile provider instead of hardcoding OSM.
window._applyTilesToNodeMap = _applyTilesToNodeMap;
// ROLE_COLORS loaded from shared roles.js
const TABS = [
{ key: 'all', label: 'All' },
{ key: 'repeater', label: 'Repeaters' },
{ key: 'room', label: 'Rooms' },
{ key: 'companion', label: 'Companions' },
{ key: 'sensor', label: 'Sensors' },
];
function buildNodesQuery(tab, searchStr) {
var parts = [];
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
// #749 — encode current sort state (default 'last_seen:desc' is omitted).
if (window.URLState) {
var st = _getSortState();
var isDefault = st.column === 'last_seen' && st.direction === 'desc';
if (!isDefault) {
var token = URLState.serializeSort(st.column, st.direction);
if (token) parts.push('sort=' + encodeURIComponent(token));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
window.buildNodesQuery = buildNodesQuery;
function updateNodesUrl() {
// Preserve subpath (e.g. #/nodes/<pubkey>) so this doesn't break detail deep-links.
var cur = String(location.hash || '');
var subpath = '';
var m = cur.match(/^#\/nodes(\/[^?]*)?/);
if (m && m[1]) subpath = m[1];
history.replaceState(null, '', '#/nodes' + subpath + buildNodesQuery(activeTab, search));
}
function renderNodeTimestampHtml(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
}
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
function renderNodeTimestampText(isoString) {
if (typeof formatTimestamp !== 'function' || typeof getTimestampMode !== 'function') {
return typeof timeAgo === 'function' ? timeAgo(isoString) : '—';
}
return formatTimestamp(isoString, getTimestampMode());
}
/* === Shared helper functions for node detail rendering === */
function renderNodeMetric(label, value, table, title = '', attributes = '') {
const tip = title ? ' title="' + escapeHtml(title) + '"' : '';
return table
? `<tr ${attributes}><td${tip}>${label}</td><td>${value}</td></tr>`
: `<dt${tip}>${label}</dt><dd ${attributes}>${value}</dd>`;
}
function nodeMetricNumber(raw) {
if (typeof raw !== 'number' && (typeof raw !== 'string' || !raw.trim())) return null;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : null;
}
function renderLastRelayed(n, table) {
if (n.role !== 'repeater' && n.role !== 'room') return '';
const hasRelay = n.last_relayed && Number.isFinite(new Date(n.last_relayed).getTime());
const certainMs = typeof n.last_certain_relay === 'string' ? Date.parse(n.last_certain_relay) : NaN;
const hasCertainRelay = Number.isFinite(certainMs);
const certainAge = Date.now() - certainMs;
let value = hasRelay
? renderNodeTimestampHtml(n.last_relayed) + ' <span style="color:var(--text-muted);font-size:11px">attributed relay observation</span>'
: '<span style="color:var(--text-muted)">' + (hasCertainRelay ? 'no attributed non-advert relay' : 'never observed as relay hop') + '</span>';
if (hasCertainRelay && certainAge >= 0 && certainAge < HEALTH_THRESHOLDS.relayActiveMs) {
value += ' <span data-certain-relay-active style="color:var(--status-green-text);font-size:11px">actively relaying</span>';
}
if (n.relay_count_1h != null || n.relay_count_24h != null) {
const count = raw => { const v = nodeMetricNumber(raw); return Number.isSafeInteger(v) ? v : '—'; };
value += ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${count(n.relay_count_1h)} relays/hr, ${count(n.relay_count_24h)} relays/24h)</span>`;
}
return renderNodeMetric('Last Relayed', value, table, "Last time this node was attributed as a relay hop in a non-advert packet observed by the network. This includes 1-byte hops shared with other repeaters, so it does not decide Active/Stale: 'Last Heard' counts only packets that can be this node alone. The green actively relaying mark requires recent certain relay evidence, including forwarding another node's advert. No mark does not imply idle or offline.");
}
function renderTrafficShare(n, table) {
if (n.role !== 'repeater' && n.role !== 'room') return '';
// Older servers expose only usefulness_score; a measured zero is present.
const raw = n.traffic_share_score != null ? n.traffic_share_score : n.usefulness_score;
const s = nodeMetricNumber(raw);
if (s === null || s > 1) return '';
const pct = (s * 100).toFixed(1);
let label, color;
if (s >= 0.8) { label = 'Critical'; color = 'var(--status-green)'; }
else if (s >= 0.6) { label = 'Valuable'; color = 'var(--status-green)'; }
else if (s >= 0.3) { label = 'Moderate'; color = 'var(--status-yellow)'; }
else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange)'; }
else { label = 'Redundant'; color = 'var(--status-red)'; }
const barWidth = Math.max(2, Math.round(s * 100));
const tooltip = "Fraction of all non-advert mesh traffic in the analyzer's memory that transited through this repeater as a relay hop. High = lots of packets pass through; low = quieter (may still be structurally important — see Bridge score).";
const attributes = (table ? 'id="row-usefulness-score" ' : '') + `data-usefulness-score="${s.toFixed(4)}" data-traffic-share-score="${s.toFixed(4)}"`;
const value = `<span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span>`;
return renderNodeMetric('Traffic share <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span>', value, table, tooltip, attributes);
}
function renderHashPrefix(n, table) {
const key = String(n.public_key || '');
const size = nodeMetricNumber(n.hash_size);
let value = Number.isSafeInteger(size) && size > 0 && size * 2 <= key.length
? '<code style="font-family:var(--mono);font-weight:700">' + escapeHtml(key.slice(0, size * 2).toUpperCase()) + '</code> (' + size + '-byte)'
: 'Unknown';
if (n.hash_size_inconsistent) {
const sizes = Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : [];
value += ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + escapeHtml(sizes.join(', ')) + '-byte"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>varies</span>';
}
return renderNodeMetric('Hash Prefix', value, table);
}
function getStatusTooltip(role, status) {
const isInfra = role === 'repeater' || role === 'room';
const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm';
if (status === 'active') {
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
}
if (status === 'uncertain') {
return 'Uncertain \u2014 nothing that can only be this ' + role + ' for over ' + threshold + '. Its hop prefix, shared with other repeaters, still appears in paths: that may be this node or another one.';
}
if (role === 'companion') {
return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.';
}
if (role === 'sensor') {
return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.';
}
return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.';
}
// #1845: how long we have heard nothing at all from this node. Infinity when
// we have never heard it, which must count as silent for every window rather
// than dropping out of the filter. Uses the shared relay-aware definition so
// this agrees with the Active/Stale badge instead of contradicting it.
function silenceAgeMs(n) {
const ms = (typeof window.getEffectiveHeardMs === 'function') ? window.getEffectiveHeardMs(n) : NaN;
return (typeof ms === 'number' && !isNaN(ms) && ms > 0) ? (Date.now() - ms) : Infinity;
}
function getStatusInfo(n) {
// Single source of truth for all status-related info
const role = (n.role || '').toLowerCase();
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
// The newest of _lastHeard (health API), last_heard and last_seen, the
// same timestamp the status uses (getEffectiveHeardMs, #2260).
const effMs = window.getEffectiveHeardMs(n);
const lastHeardMs = isNaN(effMs) ? 0 : effMs;
const lastHeardTime = lastHeardMs ? new Date(lastHeardMs).toISOString() : null;
// #1598/#2260: certain relays are already in last_heard
const status = getNodeStatus(n);
const statusTooltip = getStatusTooltip(role, status);
const statusLabel = status === 'active' ? '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Active'
: status === 'uncertain' ? '<span style="color:var(--text-muted)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg></span> Uncertain'
: '<span style="color:var(--text-muted)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Stale';
const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity;
let explanation = '';
if (status === 'active') {
// #2260: no "Last relayed" here. last_relayed also counts 1-byte prefix
// matches of other repeaters; the certain relays are in last heard.
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
} else if (status === 'uncertain') {
explanation = 'Last certainly heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'never') +
' \u2014 its shared hop prefix was last in a path ' + renderNodeTimestampText(n.last_relayed) + ', possibly another node';
} else {
const ageDays = Math.floor(statusAge / 86400000);
const ageHours = Math.floor(statusAge / 3600000);
const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h';
const isInfra = role === 'repeater' || role === 'room';
const reason = isInfra
? 'repeaters typically advertise every 12-24h'
: 'companions only advertise when user initiates, this may be normal';
explanation = 'Not heard for ' + ageStr + ' — ' + reason;
}
return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role };
}
function renderNodeBadges(n, roleColor) {
// Returns HTML for: role badge, hash prefix badge, hash inconsistency link, status label
const info = getStatusInfo(n);
let html = `<span class="badge" style="${(window.aaBadgeStyle && window.aaBadgeStyle(roleColor)) || ('background:'+roleColor+';color:#fff')}">${n.role}</span>`;
if (n.hash_size) {
html += ` <span class="badge pubkey-prefix-badge">${n.public_key.slice(0, n.hash_size * 2).toUpperCase()}</span>`;
}
// #1279 P2 #4: multibyte capability badge — surfaced from the observable
// multibyte hash_size (firmware Feat1/Feat2 carry the wire capability bits
// per AdvertDataHelpers.h:14-16, but Feat1/Feat2 aren't persisted per-node
// in CoreScope today; hash_size is the observed effective capability).
if (n.hash_size && Number(n.hash_size) >= 2) {
html += ` <span class="badge multibyte-badge" title="Node advertises multibyte hash path (firmware Feat1/Feat2)">Multibyte: ${Number(n.hash_size)}-byte</span>`;
}
if (n.hash_size_inconsistent) {
html += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> variable hash size</a>`;
}
html += ` <span title="${info.statusTooltip}">${info.statusLabel}</span>`;
return html;
}
function renderStatusExplanation(n) {
const info = getStatusInfo(n);
return `<div style="font-size:12px;color:var(--text-muted);margin:4px 0 6px"><span title="${info.statusTooltip}">${info.statusLabel}</span> — ${info.explanation}</div>`;
}
function renderHashInconsistencyWarning(n) {
if (!n.hash_size_inconsistent) return '';
const sizes = Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : [];
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${escapeHtml(sizes.join('-byte, '))}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--link-color)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--link-color)">repeater v1.14.1</a>.</div>`;
}
// ─── Neighbor section helpers ───────────────────────────────────────────────
// Cache: pubkey → { data, ts }
var _neighborCache = {};
function getConfidenceIndicator(entry) {
if (entry.ambiguous) return { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
// Issue #1638: weight observations by hash-prefix mode. Per firmware
// (firmware/src/Packet.cpp:13-18) valid wire hash modes are 1/2/3-byte
// (hash_size==4 is reserved). 1-byte prefixes collide ~8-way across a
// typical mesh (low ambiguity-resistance); 2-byte ~256-way reduces
// collision sharply; 3-byte (~16M) is effectively unambiguous. Bucket 0
// is the legacy/unknown bucket used for edges loaded from the persisted
// snapshot (no per-mode breakdown stored) — weight is conservative 0.5.
// #1784 — path trust threshold: if the configured minimum hash bytes
// for mapping is >= 2, observations below it get zero weight in the
// confidence computation (they're excluded from trusted evidence). This
// also excludes bucket 0: its legacy/unknown hash length cannot prove it
// meets a strict threshold, so refreshed observations replace its weight.
var _tt = (typeof window !== 'undefined' && window.MC_getPathTrustThreshold) ? window.MC_getPathTrustThreshold() : 1;
var modeWeight = { 0: 0.5, 1: 0.125, 2: 0.875, 3: 1.0 };
if (_tt >= 2) {
for (var _m = 0; _m < _tt; _m++) modeWeight[_m] = 0;
}
var cbm = entry.counts_by_mode || null;
var weighted;
if (cbm) {
weighted = 0;
var summed = 0;
for (var k in cbm) {
if (Object.prototype.hasOwnProperty.call(cbm, k)) {
var w = modeWeight[k] != null ? modeWeight[k] : 0.5; // unknown mode → conservative
var c = cbm[k] || 0;
weighted += w * c;
summed += c;
}
}
// If the flat Count exceeds the sum of the per-mode breakdown (e.g.
// partial breakdown after merging a legacy-snapshot edge with new
// sightings), apportion the delta to the legacy/unknown bucket so
// we honestly count every observation without inflating its weight.
var total = entry.count || 0;
if (total > summed) {
weighted += modeWeight[0] * (total - summed);
}
} else {
// Back-compat: no per-mode breakdown at all → treat all sightings as
// legacy/unknown bucket (conservative weight).
weighted = (entry.count || 0) * modeWeight[0];
}
if ((entry.count || 0) <= 1 && weighted < 1) {
return { icon: '<span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'LOW', cls: 'confidence-low' };
}
// HIGH when EITHER the legacy heuristic clears OR ≥3 unambiguous-equivalent
// sightings have accumulated (weighted ≥ 3).
if ((entry.score >= 0.5 && entry.count >= 3) || weighted >= 3) {
return { icon: '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'HIGH', cls: 'confidence-high' };
}
return { icon: '<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>', label: 'MEDIUM', cls: 'confidence-medium' };
}
function renderNeighborRows(neighbors, limit) {
var sorted = neighbors.slice().sort(function(a, b) {
return (b.count || 0) - (a.count || 0);
});
var items = limit ? sorted.slice(0, limit) : sorted;
return items.map(function(nb) {
var conf = getConfidenceIndicator(nb);
var name = nb.name || (nb.prefix + '… (unknown)');
var nameHtml = nb.pubkey
? '<a href="#/nodes/' + encodeURIComponent(nb.pubkey) + '">' + escapeHtml(name) + '</a>'
: '<span class="text-muted">' + escapeHtml(name) + '</span>';
var role = nb.role || '—';
var roleBadge = nb.role
? '<span class="badge" style="background:' + (ROLE_COLORS[nb.role] || 'var(--surface-2)') + ';color:#fff;font-size:10px">' + escapeHtml(role) + '</span>'
: '<span class="text-muted">—</span>';
var scoreTitle = 'Observations: ' + nb.count;
if (nb.counts_by_mode) {
var parts = [];
[1, 2, 4, 6].forEach(function(m) {
if (nb.counts_by_mode[m]) parts.push(m + '-byte: ' + nb.counts_by_mode[m]);
});
if (parts.length) scoreTitle += ' (' + parts.join(', ') + ')';
}
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
var distanceCell = nb.distance_km != null
? formatDistance(Number(nb.distance_km))
: '<span class="text-muted">—</span>';
var showOnMap = nb.pubkey
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> Map</button>'
: '';
var lastSeenVal = nb.last_seen ? new Date(nb.last_seen).getTime() : 0;
var distanceVal = nb.distance_km != null ? Number(nb.distance_km) : '';
return '<tr>' +
'<td data-value="' + escapeHtml(name.toLowerCase()) + '" style="font-weight:600">' + nameHtml + '</td>' +
'<td data-value="' + escapeHtml(role.toLowerCase()) + '">' + roleBadge + '</td>' +
'<td data-value="' + Number(nb.score || 0) + '" title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
'<td data-value="' + (nb.count || 0) + '">' + nb.count + '</td>' +
'<td data-value="' + lastSeenVal + '">' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
'<td data-value="' + distanceVal + '">' + distanceCell + '</td>' +
'<td><span title="' + conf.label + '">' + conf.icon + '</span></td>' +
'<td class="col-action">' + showOnMap + '</td>' +
'</tr>';
}).join('');
}
function renderNeighborTable(neighbors, limit) {
return '<table class="data-table neighbor-sort-table" style="font-size:12px">' +
'<thead><tr>' +
'<th scope="col" data-sort-key="name">Neighbor</th>' +
'<th scope="col" data-sort-key="role">Role</th>' +
'<th scope="col" data-sort-key="score" data-type="numeric" data-sort-default="desc">Score</th>' +
'<th scope="col" data-sort-key="count" data-type="numeric" data-sort-default="desc">Obs</th>' +
'<th scope="col" data-sort-key="last_seen" data-type="numeric" data-sort-default="desc">Last Seen</th>' +
'<th scope="col" data-sort-key="distance" data-type="numeric">Distance</th>' +
'<th scope="col">Conf</th><th scope="col" class="col-action"></th>' +
'</tr></thead>' +
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
}
function fetchAndRenderNeighbors(pubkey, containerId, opts) {
opts = opts || {};
var limit = opts.limit || 0;
var headerSelector = opts.headerSelector;
var viewAllPubkey = opts.viewAllPubkey;
// Always set spinner as initial DOM state (synchronous) so tests can observe it
var spinnerEl = document.getElementById(containerId);
if (spinnerEl) spinnerEl.innerHTML = '<div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div>';
// Check cache
var cached = _neighborCache[pubkey];
if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache
renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey);
return;
}
api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) {
_neighborCache[pubkey] = { data: data, ts: Date.now() };
renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey);
}).catch(function() {
var el = document.getElementById(containerId);
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Could not load neighbor data</div>';
});
}
function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) {
var el = document.getElementById(containerId);
if (!el) return;
if (!data || !data.neighbors || !data.neighbors.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.</div>';
if (headerSelector) {
var h = document.querySelector(headerSelector);
if (h) h.textContent = 'Neighbors (0)';
}
return;
}
if (headerSelector) {
var h = document.querySelector(headerSelector);
if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')';
}
var html = renderNeighborTable(data.neighbors, limit);
if (limit && data.neighbors.length > limit) {
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link show-all-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--link-color);padding:0">Show all ' + data.neighbors.length + ' neighbors <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></button></div>';
} else if (!limit && data.neighbors.length > 5) {
// Collapse toggle when expanded (#855)
html += '<div style="margin-top:6px;text-align:right"><button class="btn-link collapse-neighbors-btn" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--link-color);padding:0">Show fewer <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg></button></div>';
}
el.innerHTML = html;
// Wire "Show all neighbors" expand button (#855)
var expandBtn = el.querySelector('.show-all-neighbors-btn');
if (expandBtn) {
expandBtn.addEventListener('click', function() {
renderNeighborData(data, containerId, 0, headerSelector, null);
});
}
// Wire collapse button (#855)
var collapseBtn = el.querySelector('.collapse-neighbors-btn');
if (collapseBtn) {
collapseBtn.addEventListener('click', function() {
renderNeighborData(data, containerId, 5, headerSelector, null);
});
}
// Initialize TableSort on neighbor table
var neighborTable = el.querySelector('.neighbor-sort-table');
if (neighborTable && window.TableSort) {
TableSort.init(neighborTable, {
defaultColumn: 'count',
defaultDirection: 'desc'
});
}
// Wire up "Show on Map" buttons via event delegation
el.addEventListener('click', function(e) {
var btn = e.target.closest('.neighbor-show-map');
if (!btn) return;
var pk = btn.getAttribute('data-pubkey');
if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk);
});
}
// ─── End neighbor helpers ─────────────────────────────────────────────────
let directNode = null; // set when navigating directly to #/nodes/:pubkey
let regionChangeHandler = null;
let packetCountHelpCleanup = null;
function init(app, routeParam) {
if (window.setRouteTitle) window.setRouteTitle('nodes', routeParam);
directNode = routeParam || null;
packetCountHelpCleanup = setupPacketCountHelp(app);
if (directNode) {
// Full-screen single node view (desktop + mobile).
// Reached via the Details link or a deep link to #/nodes/{pubkey}.
// Row clicks use history.replaceState (no hashchange → no re-init),
// so the split-panel UX on desktop is preserved.
app.innerHTML = `<div class="node-fullscreen">
<div class="node-full-header">
<button class="detail-back-btn node-back-btn" id="nodeBackBtn" aria-label="Back to nodes">←</button>
<span class="node-full-title">Loading…</span>
</div>
<div class="node-full-body" id="nodeFullBody">
<div class="text-center text-muted" style="padding:40px">Loading…</div>
</div>
</div>`;
document.getElementById('nodeBackBtn').addEventListener('click', () => { location.hash = '#/nodes'; });
loadFullNode(directNode);
// Escape to go back to nodes list
document.addEventListener('keydown', function nodesEsc(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', nodesEsc);
location.hash = '#/nodes';
}
});
return;
}
// Reset list-view state to defaults, then override from URL params
activeTab = 'all';
search = '';
const _listUrlParams = getHashParams();
const _urlTab = _listUrlParams.get('tab');
const _urlSearch = _listUrlParams.get('search');
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
if (_urlSearch) search = _urlSearch;
// #1845: shareable — "here are the repeaters silent over a week".
const _urlSilent = _listUrlParams.get('silent');
if (_urlSilent !== null) silentFor = SILENT_MS[_urlSilent] ? _urlSilent : '';
loraFilter = _listUrlParams.get('lora') === 'differs' ? 'differs' : '';
// #749 — restore sort from URL (overrides localStorage persistence).
var _urlSort = _listUrlParams.get('sort');
if (_urlSort && window.URLState) {
var _parsedSort = URLState.parseSort(_urlSort);
if (_parsedSort && _parsedSort.column) {
try { localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_parsedSort)); } catch {}
_fallbackSortState = _parsedSort;
}
}
app.innerHTML = `<div class="nodes-page">
<div class="nodes-topbar">
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search by name or pubkey prefix…" aria-label="Search nodes by name or pubkey prefix">
<div class="nodes-counts" id="nodeCounts"></div>
<button class="btn-secondary nodes-export-btn" id="nodesExportBtn" disabled
title="Download the visible nodes as a MeshCore companion config">Export JSON</button>
</div>
<div id="nodesRegionFilter" class="region-filter-container"></div>
<div id="nodesAreaFilter" style="display:none"></div>
<div class="split-layout">
<div class="panel-left" id="nodesLeft" aria-live="polite" aria-relevant="additions removals"></div>
<div class="panel-right empty" id="nodesRight"><span>Select a node to view details</span></div>
</div>
</div>`;
RegionFilter.init(document.getElementById('nodesRegionFilter'));
AreaFilter.init(document.getElementById('nodesAreaFilter'));
regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; _fleetSkew = null; loadNodes(); });
AreaFilter.onChange(function () { _allNodes = null; _fleetSkew = null; loadNodes(); });
if (search) {
var _si = document.getElementById('nodeSearch');
if (_si) _si.value = search;
}
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
search = e.target.value;
updateNodesUrl();
loadNodes();
}, 250));
document.getElementById('nodesExportBtn').addEventListener('click', function () {
// WYSIWYG: `nodes` is the list the table is showing, so every active
// filter (area, region, tab, search, last-heard, status) carries over.
window.NodesExport.download(nodes, AreaFilter.getSelected());
});
loadNodes();
if (directNode) selectNode(directNode);
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
wsHandler = debouncedOnWS(function (msgs) {
const advertMsgs = msgs.filter(isAdvertMessage);
if (!advertMsgs.length) return;
if (!_allNodes) {
invalidateApiCache('/nodes');
loadNodes(true);
return;
}
let needReload = false;
for (const m of advertMsgs) {
const payload = m.data && m.data.decoded && m.data.decoded.payload;
const pubKey = payload && (payload.pubKey || payload.public_key);
if (!pubKey) { needReload = true; break; }
const existing = _allNodes.find(n => n.public_key === pubKey);
if (existing) {
if (payload.name) existing.name = payload.name;
if (payload.lat != null) existing.lat = payload.lat;
if (payload.lon != null) existing.lon = payload.lon;
const ts = m.data.packet && (m.data.packet.timestamp || m.data.packet.first_seen);
if (ts) existing.last_seen = ts;
} else {
needReload = true;
break;
}
}
if (needReload) {
_allNodes = null;
_fleetSkew = null;
invalidateApiCache('/nodes');
}
loadNodes(true);
}, 5000);
}
/**
* Fetch node detail + health data in parallel.
* Both selectNode() and loadFullNode() need the same data —
* this shared helper avoids duplicating the fetch logic (fixes #391).
*/
async function fetchNodeDetail(pubkey) {
const [nodeData, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null),
loadRfParams()
]);
nodeData.healthData = healthData;
return nodeData;
}
function renderPacketCountLabel(id) {
return `<button type="button" class="sort-help node-packet-count-help" aria-label="Total Packets help" aria-describedby="${id}">Total Packets <span aria-hidden="true">ⓘ</span><span class="sort-help-tip" id="${id}" role="tooltip">Total Packets counts distinct transmissions involving this node as an originator, destination, or resolved relay. The seen count totals observations of those transmissions; one transmission can have multiple observations.</span></button>`;
}
function setupPacketCountHelp(app) {
let activeHelp = null;
let frame = 0;
function position() {
if (!activeHelp || !activeHelp.isConnected) { activeHelp = null; return; }
const tip = activeHelp.querySelector('.sort-help-tip');
if (!tip.offsetHeight) return;
const trigger = activeHelp.getBoundingClientRect();
const bounds = activeHelp.closest('#nodesRight, #nodeFullBody').getBoundingClientRect();
const top = Math.max(0, bounds.top);
const bottom = Math.min(window.innerHeight, bounds.bottom);
const left = Math.max(0, bounds.left);
const right = Math.min(window.innerWidth, bounds.right);
if (trigger.bottom <= top || trigger.top >= bottom) {
activeHelp.classList.add('help-dismissed');
return;
}
tip.style.maxWidth = (right - left) + 'px';
tip.style.left = Math.max(left - trigger.left, Math.min(0, right - trigger.left - tip.offsetWidth)) + 'px';
const height = tip.offsetHeight;
const preferredTop = trigger.top - height < top ? trigger.bottom : trigger.top - height;
tip.style.top = (Math.max(top, Math.min(preferredTop, bottom - height)) - trigger.top) + 'px';
tip.style.bottom = 'auto';
}
function open(event) {
const help = event.target.closest('.node-packet-count-help');
if (!help || help.contains(event.relatedTarget)) return;
activeHelp = help;
help.classList.remove('help-dismissed');
if (event.type === 'pointerover') help.classList.toggle('help-touch', event.pointerType === 'touch');
position();
}
function schedule() {
if (!activeHelp || frame) return;
frame = requestAnimationFrame(() => { frame = 0; position(); });
}
function dismiss(event) {
if (event.key !== 'Escape' || !activeHelp || !activeHelp.isConnected ||
!activeHelp.querySelector('.sort-help-tip').offsetHeight) return;
activeHelp.classList.add('help-dismissed');
event.preventDefault();
event.stopImmediatePropagation();
}
app.addEventListener('pointerover', open);
app.addEventListener('focusin', open);
app.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
// Capture also dismisses hover-only help before the page's Escape shortcuts.
document.addEventListener('keydown', dismiss, true);
return () => {
app.removeEventListener('pointerover', open);
app.removeEventListener('focusin', open);
app.removeEventListener('scroll', schedule, true);
window.removeEventListener('resize', schedule);
document.removeEventListener('keydown', dismiss, true);
cancelAnimationFrame(frame);
activeHelp = null;
};
}
async function loadFullNode(pubkey) {
const body = document.getElementById('nodeFullBody');
try {
const nodeData = await fetchNodeDetail(pubkey);
const healthData = nodeData.healthData;
const n = nodeData.node;
const adverts = (nodeData.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const title = document.querySelector('.node-full-title');
if (title) title.textContent = n.name || pubkey.slice(0, 12);
const hasLoc = n.lat != null && n.lon != null;
// Health stats
const h = healthData || {};
const stats = h.stats || {};
const observers = h.observers || [];
const relayObserverCount = Number(h.relayObserverCount) || 0;
const recent = h.recentPackets || [];
// Attach health lastHeard for shared helpers. Displayed: the newest of
// it, the node's last_heard and last_seen (#2260); either fetch may be
// the fresher one.
n._lastHeard = stats.lastHeard || n.last_seen;
const effMs = window.getEffectiveHeardMs(n);
const lastHeard = isNaN(effMs) ? null : new Date(effMs).toISOString();
const si = getStatusInfo(n);
const roleColor = si.roleColor;
const statusLabel = si.statusLabel;
const statusExplanation = si.explanation;
const dupMap = buildDupNameMap(_allNodes);
const dupBadge = dupNameBadge(n.name, n.public_key, dupMap);
const dupKeys = n.name && dupMap[n.name.toLowerCase()] ? dupMap[n.name.toLowerCase()].filter(function(k) { return k !== n.public_key; }) : [];
const dupSection = dupKeys.length ? '<div class="dup-also-known" style="font-size:11px;color:var(--text-muted);margin-top:4px">Also known as: ' + dupKeys.map(function(k) { return '<a href="#/nodes/' + encodeURIComponent(k) + '" class="mono" style="font-size:11px">' + escapeHtml(k.slice(0, 12)) + '…</a>'; }).join(', ') + '</div>' : '';
removeDetailMap(body);
body.innerHTML = `
<div class="node-full-card" style="padding:12px 16px;margin-bottom:8px">
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
${dupSection}
<div style="margin:4px 0 6px">${renderNodeBadges(n, roleColor)}</div>
${renderHashInconsistencyWarning(n)}
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<button class="btn-primary" id="copyUrlBtn" style="flex:0 0 auto;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="flex:0 0 auto;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
<a href="#/observers/${encodeURIComponent(n.public_key.toUpperCase())}" class="btn-primary" title="View this pubkey as an observer" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> Observer →</a>
<span class="node-notify-slot" id="nodeNotifySlotFull"></span>
</div>
</div>
<div class="node-top-row">
${hasLoc ? `<div class="node-map-wrap"><div id="nodeFullMap" class="node-detail-map" style="height:100%;min-height:200px;border-radius:8px;overflow:hidden"></div></div>` : ''}
<div class="node-qr-wrap${hasLoc ? '' : ' node-qr-wrap--full'}">
<div class="node-qr" id="nodeFullQrCode"></div>
<div class="mono" style="font-size:10px;color:var(--text-muted);margin-top:8px;word-break:break-all;text-align:center;max-width:180px">${n.public_key.slice(0, 16)}…${n.public_key.slice(-8)}</div>
</div>
</div>
<table class="node-stats-table" id="node-stats">
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
${renderLastRelayed(n, true)}
${renderTrafficShare(n, true)}
${(n.role === 'repeater' || n.role === 'room') && n.bridge_score != null ? (() => {
// Bridge axis (issue #672 axis 2 of 4): normalized betweenness
// centrality from the neighbor-edges graph. Distinct from the
// Traffic-share score above — bridge measures STRUCTURAL
// importance (how many shortest paths between other node
// pairs go through this one) regardless of current traffic.
const b = Number(n.bridge_score) || 0;
const bpct = (b * 100).toFixed(1);
let blabel, bcolor;
if (b >= 0.5) { blabel = 'Critical bridge'; bcolor = 'var(--status-green, #2ecc71)'; }
else if (b >= 0.2) { blabel = 'Important'; bcolor = 'var(--status-green, #2ecc71)'; }
else if (b >= 0.05) { blabel = 'Some role'; bcolor = 'var(--status-yellow, #f1c40f)'; }
else if (b > 0) { blabel = 'Marginal'; bcolor = 'var(--status-orange, #e67e22)'; }
else { blabel = 'No bridge role'; bcolor = 'var(--text-muted)'; }
const bbarWidth = Math.max(2, Math.round(b * 100));
const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint.";
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="${btooltip}">Bridge score <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
})() : ''}
${(n.role === 'repeater' || n.role === 'room') && Array.isArray(n.transported_scopes) && n.transported_scopes.length ? `<tr id="row-transported-scopes"><td title="Distinct region scopes (transmissions.scope_name) of the non-advert packets whose path names this repeater by its full pubkey. Shows which regions' traffic it has carried (#1751). Packets that only carry a 1-byte hop are excluded: that byte is shared by every node with the same pubkey prefix, so it cannot say which of them relayed (#1902).">Transported scopes</td><td><span style="display:inline-flex;flex-wrap:wrap;gap:3px;vertical-align:middle">${n.transported_scopes.map(sc => '<span class="badge">' + escapeHtml(String(sc)) + '</span>').join('')}</span></td></tr>` : ''}
${'configured_scope' in n && n.configured_scope !== null ? `<tr id="row-configured-scope"><td title="Region scopes this node has CONFIGURED, confirmed via an observer /neighbors report (status=responded) — concrete evidence, distinct from observed default scope and transported scopes (#1865).${n.configured_scope_at ? ' Last confirmed ' + escapeHtml(String(n.configured_scope_at)) + '.' : ''}">Configured scope <span style="color:var(--status-green-text)" role="img" aria-label="confirmed"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg></span></td><td>${n.configured_scope === '' ? '<span style="color:var(--text-muted)">none configured</span>' : `<code style="color:var(--link-color)">${escapeHtml(n.configured_scope)}</code>`}</td></tr>` : ''}
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
<tr><td>${renderPacketCountLabel('fullNodePacketCountHelp')}</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
${stats.avgHops ? `<tr><td>Avg Hops</td><td>${stats.avgHops}</td></tr>` : ''}
${hasLoc ? `<tr><td>Location</td><td>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</td></tr>` : ''}
${renderHashPrefix(n, true)}
${rfOn() ? `<tr><td title="LoRa radio settings this node last advertised to the MeshCore map">LoRa</td><td data-testid="node-detail-lora">${RfParams.detailHtml(rfFor(n), _rfParams.presets, Date.now(), rfDev(n))}</td></tr>` : ''}
</table>
<div class="node-full-card" id="node-packets">
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution. Groups use available observations; older history may be incomplete. An observed empty direct path cannot prove an origin-local send or RF distance.">Recent Adverts (${validPackets.length})</h4>
<div class="node-activity-list">
${window.groupRecentAdverts(validPackets).map(group => `<div class="node-advert-group" data-advert-kind="${group.kind}">
<h5 title="Count in this recent sample, not the node's lifetime total.">${group.label} (${group.adverts.length})</h5>
${group.adverts.length ? group.adverts.map(p => {
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
const typeLabel = p.payload_type === 4 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Advert' : p.payload_type === 5 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg> Channel' : p.payload_type === 2 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> DM' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg> Packet';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = p.observer_name || p.observer_id;
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ${p.observation_count}</span>` : '';
// Show hash size per advert if inconsistent
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
if ((pb & 0x3F) !== 0) {
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
}
return `<div class="node-activity-item">
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
</div>`;
}).join('') : '<div class="text-muted">None in this recent sample</div>'}
</div>`).join('')}
</div>
`; })()}
</div>
${observers.length || relayObserverCount ? `<div class="node-full-card" id="node-observers">
<h4 title="Observers that received this node's own transmission off the air. An observer that only saw traffic relayed through this node is counted separately below.">Heard By &mdash; direct (${observers.length} observer${observers.length === 1 ? '' : 's'})</h4>
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0" title="Only flood-routed transmissions identify who was heard: a direct route removes the sender from the path before retransmitting (firmware Mesh.cpp, removeSelfFromPath), and an ambiguous relay hop is left unattributed rather than guessed. So an empty list is missing evidence, not proof of missing coverage.">No observation proves a direct reception here, which is not the same as being out of range.</div>'}
${observers.length ? `<table class="data-table observer-sort-table" style="font-size:12px">
<thead><tr>
<th scope="col" data-sort-key="observer">Observer</th>
<th scope="col" data-sort-key="packets" data-type="numeric" data-sort-default="desc">Packets</th>
<th scope="col" data-sort-key="snr" data-type="numeric" data-sort-default="desc">Avg SNR</th>
<th scope="col" data-sort-key="rssi" data-type="numeric" data-sort-default="desc">Avg RSSI</th>
</tr></thead>
<tbody>
${observers.map(o => `<tr>
<td data-value="${escapeHtml((o.observer_name || o.observer_id || '').toLowerCase())}" style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.can_relay === false ? ' <span class="badge-listener" title="Firmware reported repeat:off — excluded from path-hop disambiguator (#1290)">listener</span>' : (o.can_relay === true ? ' <span class="badge-repeater" title="Firmware reported repeat:on — eligible as a path hop">repeater</span>' : '')}</td>
<td data-value="${o.packetCount || 0}">${o.packetCount}</td>
<td data-value="${o.avgSnr != null ? Number(o.avgSnr) : ''}">${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
<td data-value="${o.avgRssi != null ? Number(o.avgRssi) : ''}">${o.avgRssi != null ? Number(o.avgRssi).toFixed(0) + ' dBm' : '—'}</td>
</tr>`).join('')}
</tbody>
</table>` : ''}
${relayObserverCount ? `<div class="text-muted" style="font-size:12px;padding:6px 0 0" id="node-relay-observers">Seen via relay by ${relayObserverCount} observer${relayObserverCount === 1 ? '' : 's'}. Those observers heard a repeater that forwarded this node's traffic, not this node.</div>` : ''}
</div>` : ''}
<div class="node-full-card" id="node-neighbors">
<h4 id="fullNeighborsHeader">Neighbors</h4>
<div id="fullNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
</div>
<div class="node-full-card" id="node-affinity-debug" style="display:none">
<h4 style="cursor:pointer" onclick="var body=this.parentElement.querySelector('.affinity-debug-body'); var hidden=body.style.display==='none'; body.style.display=hidden?'block':'none'; this.querySelector('.toggle-icon').innerHTML=body.style.display==='none'?'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>':'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>'"><span class="toggle-icon"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></span> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</h4>
<div class="affinity-debug-body" style="display:none">
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data…</div></div>
</div>
</div>
<div class="node-full-card" id="fullPathsSection">
<h4>Paths Through This Node</h4>
<div class="path-symbols-legend-wrapper">${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}</div>
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
</div>
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>`;
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlotFull'), n.public_key);
// Map
if (hasLoc) {
try {
detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
_applyTilesToNodeMap(detailMap);
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(escapeHtml(n.name || n.public_key.slice(0, 12)));
resizeDetailMapAfterLayout();
} catch {}
}
// Copy URL
const nodeUrl = location.origin + '/#/nodes/' + encodeURIComponent(n.public_key);
document.getElementById('copyUrlBtn')?.addEventListener('click', () => {
const btn = document.getElementById('copyUrlBtn');
window.copyToClipboard(nodeUrl, () => {
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Copied!';
setTimeout(() => btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy URL', 2000);
});
});
// Copy short URL — issue #772. Uses an 8-char pubkey prefix; the
// backend resolves it to the canonical pubkey when unambiguous.
const shortUrl = location.origin + '/#/nodes/' + n.public_key.slice(0, 8);
document.getElementById('copyShortUrlBtn')?.addEventListener('click', () => {
const btn = document.getElementById('copyShortUrlBtn');
window.copyToClipboard(shortUrl, () => {
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Copied!';
setTimeout(() => btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Copy short URL', 2000);
});
});
// Deep-link scroll: ?section=node-packets or ?section=node-packets
const hashParams = location.hash.split('?')[1] || '';
const urlParams = new URLSearchParams(hashParams);
const scrollTarget = urlParams.get('section') || (urlParams.has('highlight') ? 'node-packets' : null);
if (scrollTarget) {
const targetEl = document.getElementById(scrollTarget);
if (targetEl) setTimeout(() => targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }), 300);
}
// QR code for full-screen view
const qrFullEl = document.getElementById('nodeFullQrCode');
if (qrFullEl && typeof qrcode === 'function') {
try {
const typeMap = { companion: 1, repeater: 2, room: 3, sensor: 4 };
const contactType = typeMap[(n.role || '').toLowerCase()] || 2;
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(n.name || 'Unknown')}&public_key=${n.public_key}&type=${contactType}`;
const qr = qrcode(0, 'M');
qr.addData(meshcoreUrl);
qr.make();
qrFullEl.innerHTML = qr.createSvgTag(3, 12);
const svg = qrFullEl.querySelector('svg');
if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; }
} catch {}
}
// Initialize TableSort on observer table (full detail page)
var observerTable = document.querySelector('#node-observers .observer-sort-table');
if (observerTable && window.TableSort) {
TableSort.init(observerTable, {
defaultColumn: 'packets',
defaultDirection: 'desc'
});
}
// Fetch neighbors for this node (full-screen view)
fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', {
headerSelector: '#fullNeighborsHeader'
});
// #690 — Clock Skew detail section (full-screen view)
loadClockSkewInto(document.getElementById('node-clock-skew'), n.public_key);
// Affinity debug panel — show if debugAffinity is enabled
(function loadAffinityDebug() {
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
var panel = document.getElementById('node-affinity-debug');
if (!show || !panel) return;
panel.style.display = '';
var apiKey = localStorage.getItem('meshcore-api-key') || '';
fetch('/api/debug/affinity?node=' + encodeURIComponent(n.public_key), { headers: { 'X-API-Key': apiKey } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
var el = document.getElementById('affinityDebugContent');
if (!el) return;
var html = '';
// Edges table
if (data.edges && data.edges.length) {
html += '<h5 style="margin:8px 0 4px">Neighbor Edges (' + data.edges.length + ')</h5>';
html += '<table class="mini-table" style="width:100%;font-size:12px"><thead><tr><th>Neighbor</th><th>Score</th><th>Count</th><th>Last Seen</th><th>Observers</th><th>Status</th></tr></thead><tbody>';
data.edges.forEach(function (e) {
var neighbor = e.nodeBName || e.nodeAName || (e.nodeB || e.nodeA || '').substring(0, 8);
if (e.nodeA.toLowerCase() === n.public_key.toLowerCase()) {
neighbor = e.nodeBName || (e.nodeB || e.prefix || '?').substring(0, 8);
} else {
neighbor = e.nodeAName || (e.nodeA || '').substring(0, 8);
}
var status = e.ambiguous ? (e.unresolved ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg> Unresolved' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Ambiguous') : (e.resolved ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Auto-resolved' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Resolved');
html += '<tr><td>' + escapeHtml(neighbor) + '</td><td>' + (e.score || 0).toFixed(3) + '</td><td>' + e.weight + '</td><td>' + (e.lastSeen || '').substring(0, 10) + '</td><td>' + (e.observers || []).length + '</td><td>' + status + '</td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="text-muted" style="padding:8px">No affinity edges for this node</div>';
}
// Resolutions
if (data.resolutions && data.resolutions.length) {
html += '<h5 style="margin:12px 0 4px">Prefix Resolutions (' + data.resolutions.length + ')</h5>';
data.resolutions.forEach(function (r) {
html += '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;font-size:12px">';
html += '<b>Prefix: ' + escapeHtml(r.prefix) + '</b> → ';
if (r.method === 'auto-resolved') {
html += '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> ' + escapeHtml(r.chosenName || r.chosen || '?') + '</span>';
html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')';
} else {
html += '<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Ambiguous</span>';
if (r.ratio) html += ' (ratio=' + r.ratio.toFixed(1) + '×, threshold=' + r.thresholdApplied + '×)';
}
// Show disambiguation tier used (M4 resolveWithContext)
if (r.tier) {
var tierLabels = {
'neighbor_affinity': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-buildings"/></svg> Affinity',
'geo_proximity': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-globe"/></svg> Geo',
'gps_preference': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-pin"/></svg> GPS',
'first_match': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-dice-five"/></svg> Naive',
'unique_prefix': '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> Unique',
'no_match': '∅ None'
};
html += ' <span style="font-size:11px;opacity:0.8">[tier: ' + (tierLabels[r.tier] || escapeHtml(r.tier)) + ']</span>';
}
// Candidates table
if (r.candidates && r.candidates.length) {
html += '<div style="margin-top:4px"><table class="mini-table" style="width:100%;font-size:11px"><thead><tr><th>Candidate</th><th>Jaccard</th><th>Count</th></tr></thead><tbody>';
r.candidates.forEach(function (c) {
var highlight = r.chosen && c.pubkey === r.chosen ? ' style="background:var(--status-green-bg,rgba(34,197,94,0.1))"' : '';
html += '<tr' + highlight + '><td>' + escapeHtml(c.name || c.pubkey.substring(0, 8)) + '</td><td>' + c.jaccard.toFixed(3) + '</td><td>' + c.score + '</td></tr>';
});
html += '</tbody></table></div>';
}
html += '</div>';
});
}
// Stats summary
if (data.stats) {
html += '<h5 style="margin:12px 0 4px">Graph Stats</h5>';
html += '<div style="font-size:12px;line-height:1.6">';
html += 'Total edges: ' + data.stats.totalEdges + '<br>';
html += 'Total nodes: ' + data.stats.totalNodes + '<br>';
html += 'Resolved: ' + data.stats.resolvedCount + ' | Ambiguous: ' + data.stats.ambiguousCount + ' | Unresolved: ' + data.stats.unresolvedCount + '<br>';
html += 'Avg confidence: ' + (data.stats.avgConfidence || 0).toFixed(3) + '<br>';
html += 'Cold-start coverage: ' + (data.stats.coldStartCoverage || 0).toFixed(1) + '%<br>';
html += 'Cache age: ' + (data.stats.cacheAge || 'N/A') + ' | Last rebuild: ' + (data.stats.lastRebuild || 'N/A');
html += '</div>';
}
el.innerHTML = html;
})
.catch(function (err) {
var el = document.getElementById('affinityDebugContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load debug data: ' + escapeHtml(err.message) + '</div>';
});
})();
// Fetch paths through this node (full-screen view)
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('fullPathsContent');
if (!el) return;
if (!pathData || !pathData.paths || !pathData.paths.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
return;
}
document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
const currentPubkey = n.public_key.toLowerCase();
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey && h.pubkey.toLowerCase() === currentPubkey;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
const html = HopDisplay.renderHop(h.prefix, entry);
return isThis ? html.replace('class="hop ', 'class="hop-current hop ') : html;
}
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}"${isThis ? ' class="hop-current"' : ''}>${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">View example packet →</a></div>
</div>`;
}).join('');
}
if (pathData.paths.length <= COLLAPSE_LIMIT) {
el.innerHTML = renderPaths(pathData.paths);
} else {
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
`<button id="showAllFullPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
document.getElementById('showAllFullPaths').addEventListener('click', function() {
el.innerHTML = renderPaths(pathData.paths);
});
}
}).catch(() => {
const el = document.getElementById('fullPathsContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
});
} catch (e) {
// #1150: surface a real error state in BOTH the back-row title and the body
// when /api/nodes/{pubkey} returns 404 (or any failure). Otherwise the title
// stays "Loading…" forever and there's no link back to the Nodes list.
const msg = (e && e.message) || '';
const is404 = /\b404\b/.test(msg) || /not\s*found/i.test(msg);
// A repeater known only from its reported name has no /api/nodes row.
// Checked before the title changes, so it never flashes "not found".
if (is404 && window.ReportedNodes) {
const rep = await window.ReportedNodes.load();
const hit = window.ReportedNodes.find(rep.nodes, pubkey);
if (document.getElementById('nodeFullBody') !== body) return;
if (hit) {
const t = document.querySelector('.node-full-title');
if (t) t.textContent = (hit.name || hit.pubkey.slice(0, 12)) + ' (self-reported)';
removeDetailMap(body);
body.innerHTML = window.ReportedNodes.detailHtml(hit, rep.method);
return;
}
}
const titleEl = document.querySelector('.node-full-title');
if (titleEl) {
titleEl.textContent = is404
? 'Node not found — ' + (pubkey || '').slice(0, 12) + '…'
: 'Failed to load node';
}
const safePubkey = escapeHtml(pubkey || '');
const headline = is404 ? 'Node not found' : 'Failed to load node';
const detail = is404
? 'No node matched the requested public key on this instance. It may exist on another deployment, or it may have been evicted/blacklisted here.'
: 'The node detail API call failed: ' + escapeHtml(msg);
removeDetailMap(body);
body.innerHTML =
'<div class="node-full-card" style="padding:24px;margin:16px auto;max-width:560px;text-align:center">' +
'<div style="font-size:18px;font-weight:600;margin-bottom:8px">' + headline + '</div>' +
'<div class="mono" style="font-size:11px;color:var(--text-muted);word-break:break-all;margin-bottom:12px">' + safePubkey + '</div>' +
'<div style="color:var(--text-muted);margin-bottom:16px">' + detail + '</div>' +
'<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">' +
'<a href="#/nodes" class="btn-primary" style="text-decoration:none;padding:6px 14px">← Back to Nodes</a>' +
'<button id="nodeRetryBtn" class="btn-primary" style="padding:6px 14px">Try again</button>' +
'</div>' +
'</div>';
const retryBtn = document.getElementById('nodeRetryBtn');
if (retryBtn) {
retryBtn.addEventListener('click', function () {
if (titleEl) titleEl.textContent = 'Loading…';
body.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
loadFullNode(pubkey);
});
}
}
}
function destroy() {
if (packetCountHelpCleanup) { packetCountHelpCleanup(); packetCountHelpCleanup = null; }
if (wsHandler) offWS(wsHandler);
wsHandler = null;
removeDetailMap();
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
regionChangeHandler = null;
nodes = [];
selectedKey = null;
}
let _themeRefreshHandler = null;
let _allNodes = null; // cached full node list
let _fleetSkew = null; // cached clock skew map: pubkey → {severity, recentMedianSkewSec, medianSkewSec, ...}
let _rfParams = null; // #1268 /api/rf-params body: {enabled, presets, nodes: pubkey → {freq, sf, bw, cr, last_advert}}
// #1268 — one bulk fetch of the MeshCore map's radio settings for the
// detail views (api() caches it 5 min); a failure leaves the row off.
function loadRfParams() {
return api('/rf-params', { ttl: 300000 })
.catch(function () { return null; })
.then(function (d) { _rfParams = d; return d; });
}
// Off until the server has fetched the map once: before that every node
// would wrongly read as "not on the map".
function rfOn() { return !!(_rfParams && _rfParams.enabled && _rfParams.fetched_at); }
function rfFor(n) {
return (rfOn() && n && n.public_key && _rfParams.nodes[String(n.public_key).toLowerCase()]) || null;
}
function rfDev(n) {
return (rfOn() && n && n.public_key && _rfParams.deviations && _rfParams.deviations[String(n.public_key).toLowerCase()]) || null;
}
/**
* Fetch per-node clock skew and render into the given container element.
* Shared between the full-screen detail page and the side panel (#813, #690).
* No-op if the container is missing, the API errors, or the response lacks severity.
*/
/** Build collapsible evidence panel for node clock skew card */
function buildEvidencePanel(cs) {
var evidence = cs.recentHashEvidence;
if (!evidence || evidence.length === 0) return '';
var calSum = cs.calibrationSummary || {};
var calLine = calSum.totalSamples
? '<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px">Last ' + calSum.totalSamples + ' samples: ' + (calSum.calibratedSamples || 0) + ' corrected via observer calibration, ' + (calSum.uncalibratedSamples || 0) + ' uncorrected (single-observer).</div>'
: '';
// Severity reason.
var skewVal = window.currentSkewValue(cs);
var sampleCount = (cs.samples || []).length;
var sevLabel = SKEW_SEVERITY_LABELS[cs.severity] || cs.severity;
var reasonLine = '<div style="font-size:12px;margin-bottom:8px"><strong>Recent ' + sampleCount + ' adverts median ' + formatSkew(skewVal) + ' → ' + sevLabel + '</strong></div>';
var hashBlocks = evidence.map(function(ev) {
var shortHash = (ev.hash || '').substring(0, 8) + '…';
var obsCount = ev.observers ? ev.observers.length : 0;
// #1285: per-hash median is server-side filtered to exclude RTC-reset
// outliers (|corrected skew| > 24h). Compute the same on the client so
// we can label hashes whose observers ALL saw a reset-shaped advert as
// "insufficient data — N outliers excluded" instead of rendering 0 or
// a misleading post-filter value.
var OUTLIER_SEC = 86400;
var outlierObs = 0;
(ev.observers || []).forEach(function(o) {
if (Math.abs(o.correctedSkewSec || 0) > OUTLIER_SEC) outlierObs++;
});
var medianLabel;
if (outlierObs > 0 && outlierObs === obsCount) {
medianLabel = 'insufficient data (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)';
} else if (outlierObs > 0) {
medianLabel = formatSkew(ev.medianCorrectedSkewSec) + ' (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)';
} else {
medianLabel = formatSkew(ev.medianCorrectedSkewSec);
}
var header = '<div style="font-weight:600;font-size:12px;margin-top:6px">Hash ' + shortHash + ' · ' + obsCount + ' observer' + (obsCount !== 1 ? 's' : '') + ' · median corrected: ' + medianLabel + '</div>';
var lines = (ev.observers || []).map(function(o) {
var name = escapeHtml(o.observerName || o.observerID);
return '<div style="font-size:11px;padding-left:16px;font-family:var(--mono)">' +
name + ' raw=' + formatSkew(o.rawSkewSec) + ' corrected=' + formatSkew(o.correctedSkewSec) + ' (observer offset ' + formatSkew(o.observerOffsetSec) + ')' +
'</div>';
}).join('');
return header + lines;
}).join('');
return '<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">Evidence (' + evidence.length + ' hashes)</summary>' +
'<div style="margin-top:6px;padding:8px;background:var(--bg-secondary);border-radius:6px">' +
reasonLine + calLine + hashBlocks +
'</div></details>';
}
async function loadClockSkewInto(container, pubkey) {
if (!container) return;
try {
var cs = await api('/nodes/' + encodeURIComponent(pubkey) + '/clock-skew', { ttl: 30000 });
if (!cs || !cs.severity) return;
container.style.display = '';
var driftHtml = cs.driftPerDaySec ? '<div style="font-size:12px;color:var(--text-muted);margin-top:2px">Drift: ' + formatDrift(cs.driftPerDaySec) + '</div>' : '';
var sparkHtml = renderSkewSparkline(cs.samples, 200, 32);
var skewVal = window.currentSkewValue(cs);
var skewDisplay = cs.severity === 'no_clock'
? '<span style="font-size:18px;font-weight:700;color:var(--text-muted)">No Clock</span>'
: '<span style="font-size:18px;font-weight:700;font-family:var(--mono)">' + formatSkew(skewVal) + '</span>';
var bimodalWarning = '';
if (cs.severity === 'bimodal_clock') {
var totalRecent = cs.recentSampleCount || 0;
var summary = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ' + (cs.recentBadSampleCount || '?') + ' of last ' + (totalRecent || '?') + ' adverts had nonsense timestamps (likely RTC reset)';
var badList = '';
if (Array.isArray(cs.recentBadSamples) && cs.recentBadSamples.length) {
var items = cs.recentBadSamples.map(function(bs) {
var hash = String(bs.hash || '');
if (!hash) return '';
var iso = bs.advertTS ? new Date(bs.advertTS * 1000).toISOString() : '';
var label = iso ? formatTimestamp(iso) : '—';
return '<li><a href="#/packets/' + encodeURIComponent(hash) + '">' + escapeHtml(hash.slice(0, 8)) + '</a> → <span title="' + escapeHtml(iso) + '">' + escapeHtml(label) + '</span></li>';
}).filter(Boolean).join('');
if (items) {
badList = '<ul style="margin:4px 0 0 18px;padding:0;font-size:11px;color:var(--text-muted)">' + items + '</ul>';
}
}
bimodalWarning = '<div style="font-size:12px;color:var(--status-amber-text);margin-top:4px">' + summary + badList + '</div>';
}
container.innerHTML =
'<h4 style="margin:0 0 6px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clock"/></svg> Clock Skew</h4>' +
'<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">' +
skewDisplay +
renderSkewBadge(cs.severity, skewVal, cs) +
(cs.calibrated ? ' <span style="font-size:10px;color:var(--text-muted)" title="Observer-calibrated"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> calibrated</span>' : '') +
'</div>' +
driftHtml +
(sparkHtml ? '<div class="skew-sparkline-wrap" style="margin-top:8px">' + sparkHtml + '<div style="font-size:10px;color:var(--text-muted)">Skew over time (' + (cs.samples || []).length + ' samples)</div></div>' : '') +
bimodalWarning +
buildEvidencePanel(cs);
} catch (e) {
// Non-fatal — section stays hidden
}
}
/** Fetch fleet clock skew once, return map keyed by pubkey */
async function getFleetSkew() {
if (_fleetSkew) return _fleetSkew;
try {
const data = await api('/nodes/clock-skew', { ttl: 30000 });
_fleetSkew = {};
(Array.isArray(data) ? data : []).forEach(function(cs) {
if (cs && cs.pubkey) _fleetSkew[cs.pubkey] = cs;
});
} catch (e) {
_fleetSkew = {};
}
return _fleetSkew;
}
// Build a map of lowercased name → count of distinct pubkeys sharing that name
function buildDupNameMap(allNodes) {
var map = {};
(allNodes || []).forEach(function(n) {
if (!n.name) return;
var key = n.name.toLowerCase();
if (!map[key]) map[key] = [];
if (map[key].indexOf(n.public_key) === -1) map[key].push(n.public_key);
});
return map;
}
function dupNameBadge(name, pubkey, dupMap) {
if (!name || !dupMap) return '';
var keys = dupMap[name.toLowerCase()];
if (!keys || keys.length <= 1) return '';
var others = keys.filter(function(k) { return k !== pubkey; });
var title = keys.length + ' nodes share this name (' + others.map(function(k) { return k.slice(0, 8) + '…'; }).join(', ') + ')';
return ' <span class="dup-name-badge" title="' + escapeHtml(title) + '">(' + keys.length + ')</span>';
}
async function loadNodes(refreshOnly) {
try {
// Fetch all nodes via pagination loop — server clamps /api/nodes ?limit
// to 500 (PR #1540 / v3.8.3 DoS guard), so a single fetch silently
// truncates large deployments. Loop exit uses data.has_more — server
// total is unreliable under area filters (overwritten with
// len(filtered)), and so is the page length, since the same filters drop
// rows from the page. See #1606 and the has_more note in app.js.
if (!_allNodes) {
const PAGE_SIZE = 500;
const SAFETY_CAP = 10000; // hard ceiling to bound runaway loops
const baseParams = new URLSearchParams({ limit: String(PAGE_SIZE) });
const rp = RegionFilter.getRegionParam();
if (rp) baseParams.set('region', rp);
const ap = AreaFilter.getAreaParam();
if (ap) baseParams.set('area', ap);
// B1 fix: use local accumulator; only assign _allNodes on full success.
const accumulated = [];
let offset = 0;
let firstTotal = null; // informational only (M1: not used as loop bound)
// Kick off fleet skew in parallel with the first page only.
const skewPromise = getFleetSkew();
const rfPromise = loadRfParams(); // #1268 M3: deviation badges and filter
const nodesBody = document.getElementById('nodesBody');
while (offset < SAFETY_CAP) {
baseParams.set('offset', String(offset));
const data = await api('/nodes?' + baseParams, { ttl: CLIENT_TTL.nodeList });
if (!data || !Array.isArray(data.nodes)) break;
accumulated.push.apply(accumulated, data.nodes);
counts = data.counts || counts || {};
if (firstTotal === null && typeof data.total === 'number') firstTotal = data.total;
// M2: progress feedback between pages
if (nodesBody && offset > 0) {
const estTotal = firstTotal || '?';
nodesBody.innerHTML = '<tr><td colspan="99" style="text-align:center;padding:2em">Loading nodes\u2026 ' + accumulated.length + '/' + estTotal + '</td></tr>';
}
// Exit on has_more; fall back to a zero-length page against a server
// that predates it. A short page is NOT the end: handleNodes drops
// blacklisted / hidden / out-of-geofilter rows after the SQL LIMIT,
// so one filtered node in page 1 used to strand the whole rest of
// the list — including nodes that are actively relaying right now.
// Empty page ends it unconditionally (nothing here, nothing behind
// it); otherwise has_more decides, falling back to empty-page on a
// server that predates the flag.
if (data.nodes.length === 0) break;
if (typeof data.has_more === 'boolean' && !data.has_more) break;
offset += PAGE_SIZE;
}
// TODO(m2): per-page cache invalidation — currently each page uses
// the same CLIENT_TTL.nodeList; fine for now but could be smarter.
// Dedup by public_key (m1: handles overlapping pages)
const seen = new Map();
for (let i = 0; i < accumulated.length; i++) {
seen.set(accumulated[i].public_key, accumulated[i]);
}
_allNodes = Array.from(seen.values());
// m3: counts from first response only (subsequent pages may differ)
await skewPromise;
await rfPromise;
}
// Client-side filtering
let filtered = _allNodes;
if (activeTab !== 'all') filtered = filtered.filter(n => (n.role || '').toLowerCase() === activeTab);
if (search) {
filtered = filtered.filter(n => window._nodesMatchesSearch(n, search));
}
if (lastHeard) {
const ms = { '1h': 3600000, '2h': 7200000, '6h': 21600000, '12h': 43200000, '24h': 86400000, '48h': 172800000, '3d': 259200000, '7d': 604800000, '14d': 1209600000, '30d': 2592000000 }[lastHeard];
if (ms) filtered = filtered.filter(n => {
const t = n.last_heard || n.last_seen;
return t && (Date.now() - new Date(t).getTime()) < ms;
});
}
// Status filter (active/stale)
if (statusFilter === 'active' || statusFilter === 'stale' || statusFilter === 'uncertain') {
filtered = filtered.filter(n => getNodeStatus(n) === statusFilter); // #1598
}
// #1845: counts are taken BEFORE the silence filter is applied, so the
// dropdown keeps showing what each other window would select instead of
// collapsing to the one already chosen.
_silentCounts = {};
for (const k of Object.keys(SILENT_MS)) {
_silentCounts[k] = filtered.filter(n => silenceAgeMs(n) >= SILENT_MS[k]).length;
}
if (silentFor && SILENT_MS[silentFor]) {
filtered = filtered.filter(n => silenceAgeMs(n) >= SILENT_MS[silentFor]);
}
// #1268 M3: count before the LoRa filter, like the silence counts.
updateLoraFilter(filtered.filter(n => rfDev(n)).length);
if (loraFilter === 'differs' && rfOn()) filtered = filtered.filter(n => rfDev(n));
nodes = filtered;
// Defensive filter: hide nodes with obviously corrupted data
nodes = nodes.filter(n => {
if (n.public_key && n.public_key.length < 16) return false;
if (!n.name && !n.advert_count) return false;
return true;
});
// Ensure claimed nodes are always present even if not in current page
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const existingKeys = new Set(nodes.map(n => n.public_key));
const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey));
if (missing.length) {
const fetched = await Promise.allSettled(
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail }))
);
fetched.forEach(r => {
if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value);
});
}
// Auto-sync claimed → favorites
syncClaimedToFavorites();
renderCounts();
updateExportBtn();
if (refreshOnly) {
renderRows();
} else {
renderLeft();
}
} catch (e) {
console.error('Failed to load nodes:', e);
const tbody = document.getElementById('nodesBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:24px;color:var(--error,#ef4444)"><div role="alert" aria-live="polite">Failed to load nodes. Please try again.</div></td></tr>';
} finally {
// Always signal data-loaded — even on error — so E2E tests can proceed.
var nodesContainer = document.getElementById('nodesLeft') || document.getElementById('nodesBody');
if (nodesContainer) nodesContainer.setAttribute('data-loaded', 'true');
}
}
function updateExportBtn() {
const btn = document.getElementById('nodesExportBtn');
if (!btn || !window.NodesExport) return;
const n = window.NodesExport.buildContacts(nodes).contacts.length;
btn.disabled = n === 0;
btn.textContent = n ? 'Export JSON (' + n + ')' : 'Export JSON';
}
// #1268 M3: the LoRa filter only exists while the server has map data.
// renderLeft rebuilds it from this state; a refresh-only load (no
// renderLeft) updates the existing element.
function updateLoraFilter(count) {
_loraDiffCount = count;
const sel = document.getElementById('nodeLoraFilter');
if (!sel) return;
sel.hidden = !rfOn();
if (sel.options[1]) sel.options[1].textContent = 'LoRa: differs from nearby (' + count + ')';
}
function renderCounts() {
const el = document.getElementById('nodeCounts');
if (!el) return;
el.innerHTML = [
{ k: 'repeaters', l: 'Repeaters', c: ROLE_COLORS.repeater },
{ k: 'rooms', l: 'Rooms', c: ROLE_COLORS.room || '#6b7280' },
{ k: 'companions', l: 'Companions', c: ROLE_COLORS.companion },
{ k: 'sensors', l: 'Sensors', c: ROLE_COLORS.sensor },
].map(r => `<span class="node-count-pill" style="background:${r.c}">${counts[r.k] || 0} ${r.l}</span>`).join('');
}
function renderLeft() {
const el = document.getElementById('nodesLeft');
if (!el) return;
el.innerHTML = `
<div class="nodes-tabs-bar">
<div class="nodes-tabs" id="nodeTabs">
${TABS.map(t => `<button class="node-tab ${activeTab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}</button>`).join('')}
</div>
<div class="nodes-filters">
<div class="filter-group" id="nodeStatusFilter">
<button class="btn ${statusFilter==='all'?'active':''}" data-status="all">All</button>
<button class="btn ${statusFilter==='active'?'active':''}" data-status="active">Active</button>
<button class="btn ${statusFilter==='uncertain'?'active':''}" data-status="uncertain" title="No packet that can only be this repeater within its window; only its shared hop prefix still appears in paths (#2260)">Uncertain</button>
<button class="btn ${statusFilter==='stale'?'active':''}" data-status="stale">Stale</button>
</div>
<select id="nodeLastHeard" aria-label="Filter by last heard time">
<option value="">Last Heard: Any</option>
<option value="1h" ${lastHeard==='1h'?'selected':''}>1 hour</option>
<option value="2h" ${lastHeard==='2h'?'selected':''}>2 hours</option>
<option value="6h" ${lastHeard==='6h'?'selected':''}>6 hours</option>
<option value="12h" ${lastHeard==='12h'?'selected':''}>12 hours</option>
<option value="24h" ${lastHeard==='24h'?'selected':''}>24 hours</option>
<option value="48h" ${lastHeard==='48h'?'selected':''}>48 hours</option>
<option value="3d" ${lastHeard==='3d'?'selected':''}>3 days</option>
<option value="7d" ${lastHeard==='7d'?'selected':''}>7 days</option>
<option value="14d" ${lastHeard==='14d'?'selected':''}>14 days</option>
<option value="30d" ${lastHeard==='30d'?'selected':''}>30 days</option>
</select>
<select id="nodeSilentFor" aria-label="Show only nodes silent longer than">
<option value="">Silent for: Any</option>
${['1d','3d','7d','14d','30d'].map(k => `<option value="${k}" ${silentFor===k?'selected':''}>over ${k}${_silentCounts ? ` (${_silentCounts[k]})` : ''}</option>`).join('')}
</select>
<select id="nodeLoraFilter" aria-label="Show only nodes whose LoRa settings differ from nearby nodes" ${rfOn() ? '' : 'hidden'}>
<option value="">LoRa: Any</option>
<option value="differs" ${loraFilter==='differs'?'selected':''}>LoRa: differs from nearby (${_loraDiffCount})</option>
</select>
</div>
</div>
<div class="table-fluid-wrap"><table class="data-table" id="nodesTable">
<thead><tr>
<th scope="col" data-sort-key="name" data-priority="1">Name</th>
<th scope="col" class="col-pubkey" data-sort-key="public_key" data-priority="3">Public Key</th>
<th scope="col" data-sort-key="role" data-priority="2">Role</th>
<th scope="col" data-sort-key="default_scope" data-priority="3">Scope</th>
<th scope="col" data-sort-key="last_seen" data-sort-default="desc" data-priority="1">Last Seen</th>
<th scope="col" data-sort-key="first_seen" data-sort-default="desc" data-priority="3">First Seen</th>
<th scope="col" data-sort-key="advert_count" data-sort-default="desc" data-priority="2">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table></div>
<div id="reportedSection"></div>`;
// Tab clicks
const nodeTabs = document.getElementById('nodeTabs');
initTabBar(nodeTabs);
el.querySelectorAll('.node-tab').forEach(btn => {
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
});
// Filter changes
document.getElementById('nodeLastHeard').addEventListener('change', e => { lastHeard = e.target.value; localStorage.setItem('meshcore-nodes-last-heard', lastHeard); loadNodes(); });
// #1845: mirrors the Last Heard handler, and additionally keeps the URL in
// step so the current view can be pasted to whoever owns the silent gear.
document.getElementById('nodeSilentFor').addEventListener('change', e => {
silentFor = e.target.value;
localStorage.setItem('meshcore-nodes-silent-for', silentFor);
try {
const base = String(location.hash || '#/nodes').split('?')[0];
const params = getHashParams();
if (silentFor) { params.set('silent', silentFor); } else { params.delete('silent'); }
const qs = params.toString();
history.replaceState(null, '', base + (qs ? '?' + qs : ''));
} catch (err) { /* URL sync is a convenience; never block the filter on it */ }
loadNodes();
});
// #1268 M3: same URL handling as Silent for, so the list can be shared.
document.getElementById('nodeLoraFilter').addEventListener('change', e => {
loraFilter = e.target.value;
try {
const base = String(location.hash || '#/nodes').split('?')[0];
const params = getHashParams();
if (loraFilter) { params.set('lora', loraFilter); } else { params.delete('lora'); }
const qs = params.toString();
history.replaceState(null, '', base + (qs ? '?' + qs : ''));
} catch (err) { /* URL sync is a convenience; never block the filter on it */ }
loadNodes();
});
// Status filter buttons
document.querySelectorAll('#nodeStatusFilter .btn').forEach(btn => {
btn.addEventListener('click', () => {
statusFilter = btn.dataset.status;
localStorage.setItem('meshcore-nodes-status-filter', statusFilter);
document.querySelectorAll('#nodeStatusFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.status === statusFilter));
loadNodes();
});
});
// Initialize TableSort on nodes table (handles header clicks, indicators, persistence)
// We use onSort callback to re-render rows (sorting is done at JS-array level in renderRows
// because of claimed/favorites pinning logic that TableSort can't handle)
var nodesTableEl = document.getElementById('nodesTable');
if (nodesTableEl && window.TableSort) {
_nodesTableSortCtrl = TableSort.init(nodesTableEl, {
defaultColumn: 'last_seen',
defaultDirection: 'desc',
storageKey: 'meshcore-nodes-sort',
onSort: function () { renderRows(); updateNodesUrl(); }
});
}
// Delegated click/keyboard handler for table rows
const tbody = document.getElementById('nodesBody');
if (tbody) {
const handler = (e) => {
const row = e.target.closest('tr[data-action="select"]');
if (!row) return;
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
selectNode(row.dataset.value);
};
tbody.addEventListener('click', handler);
tbody.addEventListener('keydown', handler);
}
// Escape to close node detail panel
document.addEventListener('keydown', function nodesPanelEsc(e) {
if (e.key === 'Escape') {
const panel = document.getElementById('nodesRight');
if (panel && !panel.classList.contains('empty')) {
removeDetailMap();
panel.classList.add('empty');
panel.innerHTML = '<span>Select a node to view details</span>';
selectedKey = null;
history.replaceState(null, '', '#/nodes');
if (window.setRouteTitle) window.setRouteTitle('nodes');
renderRows();
}
}
});
// #630: Close button for node detail panel (important for mobile full-screen overlay)
document.getElementById('nodesRight').addEventListener('click', function(e) {
// #778/#856: Analytics link — force hashchange via replaceState + assign.
// (Details button is handled separately via .node-detail-btn click listener)
var link = e.target.closest('a.btn-primary[href^="#/nodes/"]');
if (link) {
e.preventDefault();
var href = link.getAttribute('href');
if (href.indexOf('/analytics') !== -1 || href.indexOf('/reach') !== -1) {
// Analytics/Reach link — different page, force hashchange via replaceState + assign
history.replaceState(null, '', '#/');
location.hash = href.substring(1);
}
return;
}
if (e.target.closest('.panel-close-btn')) {
const panel = document.getElementById('nodesRight');
removeDetailMap();
panel.classList.add('empty');
panel.innerHTML = '<span>Select a node to view details</span>';
selectedKey = null;
history.replaceState(null, '', '#/nodes');
if (window.setRouteTitle) window.setRouteTitle('nodes');
renderRows();
}
});
renderRows();
}
// renderReported lists repeaters known only from their reported name
// (reported-nodes.js) under the table, on the All and Repeaters tabs, with
// the same search text applied.
// renderRows runs on every sort, select and refresh, so the section is only
// rebuilt when the element, the tab, the search text or the list changed.
let reportedShown = null; // { el, tab, q, nodes }
function reportedTab() { return activeTab === 'all' || activeTab === 'repeater'; }
function renderReported() {
const el = document.getElementById('reportedSection');
if (!el || !window.ReportedNodes) return;
if (!reportedTab()) { el.innerHTML = ''; reportedShown = null; return; }
const q = search, tab = activeTab;
window.ReportedNodes.load().then(function (rep) {
const cur = document.getElementById('reportedSection');
// The tab or the search may have changed while the list loaded.
if (!cur || q !== search || tab !== activeTab || !reportedTab()) return;
const s = reportedShown;
if (s && s.el === cur && s.tab === tab && s.q === q && s.nodes === rep.nodes) return;
cur.innerHTML = window.ReportedNodes.sectionHtml(rep.nodes, rep.method, q);
reportedShown = { el: cur, tab: tab, q: q, nodes: rep.nodes };
});
}
function renderRows() {
renderReported();
const tbody = document.getElementById('nodesBody');
if (!tbody) return;
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted" style="padding:24px">No nodes found</td></tr>';
return;
}
// Claimed ("My Mesh") nodes always on top, then favorites, then sort
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const myKeys = new Set(myNodes.map(n => n.pubkey));
const favs = getFavorites();
const sorted = sortNodes([...nodes]);
// Stable re-sort: claimed first, then favorites, preserving sort within each group
sorted.sort((a, b) => {
const aMy = myKeys.has(a.public_key) ? 0 : 1;
const bMy = myKeys.has(b.public_key) ? 0 : 1;
if (aMy !== bMy) return aMy - bMy;
const aFav = favs.includes(a.public_key) ? 0 : 1;
const bFav = favs.includes(b.public_key) ? 0 : 1;
return aFav - bFav;
});
const dupMap = buildDupNameMap(_allNodes);
// #1616 followup: capture the focused row's data-key BEFORE we replace
// the tbody — async events (`theme-refresh`, ws node updates, etc.)
// can fire renderRows() while the user is keyboard-navigating. Without
// restoration, focus drops to <body> on every re-render and the
// slide-over close-path focus-restore loses the race.
const tbodyHadFocus = tbody.contains(document.activeElement);
const focusedKey = tbodyHadFocus
? (document.activeElement.closest('tr[data-key]') || {}).getAttribute &&
document.activeElement.closest('tr[data-key]').getAttribute('data-key')
: null;
tbody.innerHTML = sorted.map(n => {
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const isClaimed = myKeys.has(n.public_key);
const lastSeenTime = n.last_heard || n.last_seen;
const status = getNodeStatus(n); // #1598: relay-aware for infra
const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale';
const cs = _fleetSkew && _fleetSkew[n.public_key];
const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : '';
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-star-fill"/></svg></span> ' : ''}<strong>${escapeHtml(n.name || '(unnamed)')}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}${skewBadgeHtml}${RfParams.deviationBadge(rfFor(n), rfDev(n))}</td>
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="${(window.aaBadgeStyle && window.aaBadgeStyle(roleColor)) || ('background:'+roleColor+';color:#fff')}">${n.role}</span></td>
<td style="font-family:var(--mono);font-size:12px">${n.default_scope ? escapeHtml(n.default_scope) : ''}</td>
<td class="${lastSeenClass}">${renderNodeTimestampHtml(n.last_heard || n.last_seen)}</td>
<td>${n.first_seen ? renderNodeTimestampHtml(n.first_seen) : '<span class="text-muted">—</span>'}</td>
<td>${n.advert_count || 0}</td>
</tr>`;
}).join('');
bindFavStars(tbody);
makeColumnsResizable('#nodesTable', 'meshcore-nodes-col-widths');
// #1056: fluid columns + +N hidden pill
if (window.TableResponsive) {
var _ndTbl = document.getElementById('nodesTable');
if (_ndTbl) window.TableResponsive.register(_ndTbl);
}
// #1616 followup: re-land focus on the replacement <tr> with the
// same data-key, if the prior tbody had focus. Without this, an
// async re-render (theme-refresh, ws node update) blurs the row
// the user just keyboard-navigated to.
if (focusedKey) {
try {
const sel = (window.CSS && CSS.escape) ? CSS.escape(focusedKey) : focusedKey;
const fresh = tbody.querySelector('tr[data-key="' + sel + '"]');
if (fresh) fresh.focus({ preventScroll: true });
} catch (_) {}
}
}
/**
* Navigate to the full-screen node view for `pubkey` from anywhere within
* the nodes module. Single source of navigation truth — works regardless
* of current hash state (hash assignment alone is a no-op when the hash
* is already the target).
*/
function navigateToNode(pubkey) {
destroy();
var appEl = document.getElementById('app');
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
init(appEl, pubkey);
}
async function selectNode(pubkey) {
// On mobile, navigate to full-screen node view
if (window.innerWidth <= 640) {
location.hash = '#/nodes/' + encodeURIComponent(pubkey);
return;
}
if (window.setRouteTitle) window.setRouteTitle('nodes', pubkey);
// #1056 AC#4: narrow desktop/tablet (641–1023) — open detail in slide-over.
if (window.SlideOver && window.SlideOver.shouldUse()) {
selectedKey = pubkey;
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
renderRows();
const so = window.SlideOver.open({
title: 'Node detail',
// Resolver runs after onClose re-renders rows, so look the row up
// by data-key after the new tbody is in place.
restoreFocus: function () {
return document.querySelector('#nodesTable tbody tr[data-key="'
+ (window.CSS && CSS.escape ? CSS.escape(pubkey) : pubkey)
+ '"]');
},
onClose: function () {
selectedKey = null;
history.replaceState(null, '', '#/nodes');
if (window.setRouteTitle) window.setRouteTitle('nodes');
renderRows();
}
});
so.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const data = await fetchNodeDetail(pubkey);
if (selectedKey !== pubkey) return;
const n = (data && data.node) || data || {};
const titleEl = document.querySelector('.slide-over-title');
const name = n.name || n.advert_name;
if (titleEl) titleEl.textContent = name || (n.public_key ? n.public_key.slice(0, 10) : 'Node');
var role = (n.role || '').toString();
var lastHeard = n.last_heard || n.last_seen;
so.innerHTML =
'<dl style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
'<dt>Name</dt><dd>' + escapeHtml(name || '—') + '</dd>' +
'<dt>Role</dt><dd>' + escapeHtml(role || '—') + '</dd>' +
'<dt>Public key</dt><dd class="mono" style="word-break:break-all">' + escapeHtml(n.public_key || '—') + '</dd>' +
'<dt>Last Heard</dt><dd>' + renderNodeTimestampHtml(lastHeard) + '</dd>' +
renderLastRelayed(n) +
renderTrafficShare(n) +
'<dt>Adverts</dt><dd>' + (n.advert_count != null ? n.advert_count : '—') + '</dd>' +
renderHashPrefix(n) +
'</dl>' +
'<p style="margin-top:14px"><a class="btn-primary" href="#/nodes/' + encodeURIComponent(pubkey) + '">Open full detail →</a></p>';
} catch (e) {
so.innerHTML = '<div class="text-muted">Error: ' + (e && e.message ? e.message : String(e)) + '</div>';
}
return;
}
selectedKey = pubkey;
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
renderRows();
const panel = document.getElementById('nodesRight');
panel.classList.remove('empty');
removeDetailMap();
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const data = await fetchNodeDetail(pubkey);
if (selectedKey !== pubkey || !panel.isConnected) return;
renderDetail(panel, data);
} catch (e) {
if (selectedKey !== pubkey || !panel.isConnected) return;
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
}
function renderDetail(panel, data) {
const n = data.node;
const adverts = (data.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const h = data.healthData || {};
const stats = h.stats || {};
const observers = h.observers || [];
const relayObserverCount = Number(h.relayObserverCount) || 0;
const recent = h.recentPackets || [];
const hasLoc = n.lat != null && n.lon != null;
const nodeUrl = location.origin + '/#/nodes/' + encodeURIComponent(n.public_key);
// Status calculation via shared helper. Displayed: the newest of the
// health lastHeard, the node's last_heard and last_seen (#2260).
n._lastHeard = stats.lastHeard || n.last_seen;
const effMs = window.getEffectiveHeardMs(n);
const lastHeard = isNaN(effMs) ? null : new Date(effMs).toISOString();
const si = getStatusInfo(n);
const roleColor = si.roleColor;
const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0;
const dupMap = buildDupNameMap(_allNodes);
const dupBadge = dupNameBadge(n.name, n.public_key, dupMap);
removeDetailMap(panel);
panel.innerHTML = `
<button class="panel-close-btn" title="Close detail pane (Esc)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
<div class="node-detail">
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Details</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
<span class="node-notify-slot" id="nodeNotifySlot"></span>
</div>
${renderStatusExplanation(n)}
${hasLoc ? `<div class="node-map-qr-wrap">
<div class="node-map-container node-detail-map" id="nodeMap" style="border-radius:8px;overflow:hidden;"></div>
<div class="node-map-qr-overlay node-qr" id="nodeQrCode"></div>
</div>` : `<div class="node-qr" id="nodeQrCode" style="margin:8px 0"></div>`}
<div class="node-detail-section">
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:4px">${n.public_key}</div>
</div>
<div class="node-detail-section">
<h4>Overview</h4>
<dl class="detail-meta">
<dt>Last Heard</dt><dd>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</dd>
${renderLastRelayed(n)}
${renderTrafficShare(n)}
<dt>First Seen</dt><dd>${renderNodeTimestampHtml(n.first_seen)}</dd>
<dt>${renderPacketCountLabel('nodePacketCountHelp')}</dt><dd>${totalPackets}</dd>
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
${hasLoc ? `<dt>Location</dt><dd>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</dd>` : ''}
${renderHashPrefix(n)}
${rfOn() ? `<dt title="LoRa radio settings this node last advertised to the MeshCore map">LoRa</dt><dd data-testid="node-detail-lora">${RfParams.detailHtml(rfFor(n), _rfParams.presets, Date.now(), rfDev(n))}</dd>` : ''}
</dl>
</div>
<div class="node-detail-section">
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
<h4 title="Adverts this node originated. The section is limited to adverts because they are the only packet type attributable to an originating node: transmissions.from_pubkey is populated for ADVERTs only, so a relayed CHAN or TXT packet cannot be traced back to its sender without path resolution. Groups use available observations; older history may be incomplete. An observed empty direct path cannot prove an origin-local send or RF distance.">Recent Adverts (${validPackets.length})</h4>
<div id="advertTimeline">
${window.groupRecentAdverts(validPackets).map(group => `<div class="node-advert-group" data-advert-kind="${group.kind}">
<h5 title="Count in this recent sample, not the node's lifetime total.">${group.label} (${group.adverts.length})</h5>
${group.adverts.length ? group.adverts.map(a => {
let decoded;
try { decoded = JSON.parse(a.decoded_json); } catch {}
const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet';
const icon = a.payload_type === 4 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>' : a.payload_type === 5 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg>' : a.payload_type === 2 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-package"/></svg>';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = a.observer_name || a.observer_id;
return `<div class="advert-entry">
<span class="advert-dot" style="background:${roleColor}"></span>
<div class="advert-info">
<strong>${renderNodeTimestampHtml(a.timestamp)}</strong> ${icon} ${pType}${detail}
${a.observation_count > 1 ? ' <span class="badge badge-obs"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + a.observation_count + '</span>' : ''}
${obs ? ' via ' + escapeHtml(obs) : ''}
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
<br><a href="#/packets/${a.hash}" class="ch-analyze-link">Analyze →</a>
</div>
</div>`;
}).join('') : '<div class="text-muted">None in this recent sample</div>'}
</div>`).join('')}
</div>
`; })()}
</div>
${observers.length || relayObserverCount ? `<div class="node-detail-section">
<h4 title="Observers that received this node's own transmission off the air.">Heard By &mdash; direct (${observers.length} observer${observers.length === 1 ? '' : 's'})</h4>
${observers.length ? '' : '<div class="text-muted" style="font-size:12px;padding:4px 0" title="Only flood-routed transmissions identify who was heard: a direct route removes the sender from the path before retransmitting (firmware Mesh.cpp, removeSelfFromPath), and an ambiguous relay hop is left unattributed rather than guessed. So an empty list is missing evidence, not proof of missing coverage.">No observation proves a direct reception here, which is not the same as being out of range.</div>'}
<div class="observer-list">
${observers.map(o => {
const stats = [`${o.packetCount} pkts`];
if (o.avgSnr != null) stats.push('SNR ' + Number(o.avgSnr).toFixed(1) + 'dB');
if (o.avgRssi != null) stats.push('RSSI ' + Number(o.avgRssi).toFixed(0));
return `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</span>
<span style="color:var(--text-muted)">${stats.join(' · ')}</span>
</div>`;
}).join('')}
</div>
${relayObserverCount ? `<div class="text-muted" style="font-size:12px;padding:6px 0 0">Seen via relay by ${relayObserverCount} observer${relayObserverCount === 1 ? '' : 's'}.</div>` : ''}
</div>` : ''}
<div class="node-detail-section" id="panelNeighborsSection">
<h4 id="panelNeighborsHeader">Neighbors</h4>
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
</div>
<div class="node-detail-section" id="pathsSection">
<h4>Paths Through This Node</h4>
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
</div>
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
</div>`;
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlot'), n.public_key);
// Init map
if (hasLoc) {
try {
detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
_applyTilesToNodeMap(detailMap);
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(escapeHtml(n.name || n.public_key.slice(0, 12)));
resizeDetailMapAfterLayout();
} catch {}
}
// QR code — meshcore://contact/add format (scannable by MeshCore app)
const qrEl = document.getElementById('nodeQrCode');
if (qrEl && typeof qrcode === 'function') {
try {
const typeMap = { companion: 1, repeater: 2, room: 3, sensor: 4 };
const contactType = typeMap[(n.role || '').toLowerCase()] || 2;
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(n.name || 'Unknown')}&public_key=${n.public_key}&type=${contactType}`;
const qr = qrcode(0, 'M');
qr.addData(meshcoreUrl);
qr.make();
const isOverlay = !!qrEl.closest('.node-map-qr-overlay');
qrEl.innerHTML = qr.createSvgTag(3, 12);
const svg = qrEl.querySelector('svg');
if (svg) {
svg.style.display = 'block'; svg.style.margin = '0 auto';
// Make QR background transparent for map overlay
if (isOverlay) {
svg.querySelectorAll('rect').forEach(r => {
const fill = (r.getAttribute('fill') || '').toLowerCase();
if (fill === '#ffffff' || fill === 'white' || fill === '#fff') {
r.setAttribute('fill', 'transparent');
}
});
}
}
} catch {}
}
// Wire "Details" button via the unified navigateToNode helper
var detailBtn = panel.querySelector('.node-detail-btn');
if (detailBtn) {
detailBtn.addEventListener('click', function() {
navigateToNode(decodeURIComponent(detailBtn.getAttribute('data-pubkey')));
});
}
// Fetch neighbors for this node (condensed panel — top 5)
fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', {
limit: 5,
headerSelector: '#panelNeighborsHeader',
viewAllPubkey: n.public_key
});
// #813 — Clock Skew section in side panel (mirrors full-screen view)
loadClockSkewInto(document.getElementById('node-clock-skew'), n.public_key);
// Fetch paths through this node
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('pathsContent');
if (!el) return;
if (!pathData || !pathData.paths || !pathData.paths.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
document.querySelector('#pathsSection h4').textContent = 'Paths Through This Node';
return;
}
document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
const showAll = pathData.paths.length <= COLLAPSE_LIMIT;
const currentPubkey = n.public_key.toLowerCase();
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey && h.pubkey.toLowerCase() === currentPubkey;
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}"${isThis ? ' class="hop-current"' : ''}>${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">View example packet →</a></div>
</div>`;
}).join('');
}
if (showAll) {
el.innerHTML = renderPaths(pathData.paths);
} else {
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
`<button id="showAllPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
document.getElementById('showAllPaths').addEventListener('click', function() {
el.innerHTML = renderPaths(pathData.paths);
});
}
}).catch(() => {
const el = document.getElementById('pathsContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
});
}
function isAdvertMessage(m) {
if (m.type !== 'packet') return false;
if (m.data && m.data.packet && m.data.packet.payload_type === 4) return true;
if (m.data && m.data.decoded && m.data.decoded.header && m.data.decoded.header.payloadTypeName === 'ADVERT') return true;
return false;
}
registerPage('nodes', {
init: function(app, routeParam) {
_themeRefreshHandler = () => {
if (directNode) loadFullNode(directNode);
else {
renderRows();
if (selectedKey) selectNode(selectedKey);
}
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
return destroy();
}
});
// Test hooks
window._nodesIsAdvertMessage = isAdvertMessage;
window._nodesGetAllNodes = function() { return _allNodes; };
window._nodesSetAllNodes = function(n) { _allNodes = n; };
window._nodesToggleSort = function(col) {
if (_nodesTableSortCtrl) { _nodesTableSortCtrl.sort(col); return; }
// Fallback for tests without DOM
var st = _getSortState();
var descDefault = ['last_seen', 'advert_count'];
if (st.column === col) {
_fallbackSortState = { column: col, direction: st.direction === 'asc' ? 'desc' : 'asc' };
} else {
_fallbackSortState = { column: col, direction: descDefault.indexOf(col) >= 0 ? 'desc' : 'asc' };
}
localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_fallbackSortState));
};
window._nodesSortNodes = sortNodes;
window._nodesSortArrow = function(col) {
var st = _getSortState();
if (st.column !== col) return '';
return '<span class="sort-arrow">' + (st.direction === 'asc' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>') + '</span>';
};
window._nodesGetSortState = _getSortState;
window._nodesSetSortState = function(s) {
_fallbackSortState = s;
if (_nodesTableSortCtrl) _nodesTableSortCtrl.sort(s.column, s.direction);
};
window._nodesSyncClaimedToFavorites = syncClaimedToFavorites;
window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml;
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
window._nodesGetStatusInfo = getStatusInfo;
window._nodesGetStatusTooltip = getStatusTooltip;
// #862: Expose search filter logic for testing
window._nodesMatchesSearch = function(node, query) {
if (!query) return true;
var q = query.toLowerCase();
var isHex = /^[0-9a-f]+$/i.test(q);
if ((node.name || '').toLowerCase().includes(q)) return true;
if (isHex && (node.public_key || '').toLowerCase().startsWith(q)) return true;
return false;
};
})();