mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 19:25:52 +00:00
1291 lines
67 KiB
JavaScript
1291 lines
67 KiB
JavaScript
/* === MeshCore Analyzer — analytics.js (v2 — full nerd mode) === */
|
||
'use strict';
|
||
|
||
(function () {
|
||
let _analyticsData = {};
|
||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||
|
||
// --- SVG helpers ---
|
||
function sparkSvg(data, color, w = 120, h = 32) {
|
||
if (!data.length) return '';
|
||
const max = Math.max(...data, 1);
|
||
const pts = data.map((v, i) => {
|
||
const x = i * (w / Math.max(data.length - 1, 1));
|
||
const y = h - 2 - (v / max) * (h - 4);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Sparkline showing trend of ${data.length} data points"><title>Sparkline showing trend of ${data.length} data points</title><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||
}
|
||
|
||
function barChart(data, labels, colors, w = 800, h = 220, pad = 40) {
|
||
const max = Math.max(...data, 1);
|
||
const barW = Math.min((w - pad * 2) / data.length - 2, 30);
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Bar chart showing data distribution"><title>Bar chart showing data distribution</title>`;
|
||
// Grid
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = pad + (h - pad * 2) * i / 4;
|
||
const val = Math.round(max * (4 - i) / 4);
|
||
svg += `<line x1="${pad}" y1="${y}" x2="${w-pad}" y2="${y}" stroke="var(--border)" stroke-dasharray="2"/>`;
|
||
svg += `<text x="${pad-4}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-muted)">${val}</text>`;
|
||
}
|
||
data.forEach((v, i) => {
|
||
const x = pad + i * ((w - pad * 2) / data.length) + barW / 2;
|
||
const bh = (v / max) * (h - pad * 2);
|
||
const y = h - pad - bh;
|
||
const c = typeof colors === 'string' ? colors : colors[i % colors.length];
|
||
svg += `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" fill="${c}" rx="2"/>`;
|
||
if (labels[i]) svg += `<text x="${x + barW/2}" y="${h - pad + 14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${labels[i]}</text>`;
|
||
});
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function histogram(values, bins, color, w = 800, h = 180) {
|
||
const min = Math.min(...values), max = Math.max(...values);
|
||
const step = (max - min) / bins;
|
||
const buckets = Array(bins).fill(0);
|
||
const labels = [];
|
||
for (let i = 0; i < bins; i++) labels.push((min + step * i).toFixed(1));
|
||
values.forEach(v => { const b = Math.min(Math.floor((v - min) / step), bins - 1); buckets[b]++; });
|
||
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
|
||
}
|
||
|
||
// --- Main ---
|
||
async function init(app) {
|
||
app.innerHTML = `
|
||
<div class="analytics-page">
|
||
<div class="analytics-header">
|
||
<h2>📊 Mesh Analytics</h2>
|
||
<p class="text-muted">Deep dive into your mesh network data</p>
|
||
<div class="analytics-tabs" id="analyticsTabs">
|
||
<button class="tab-btn active" data-tab="overview">Overview</button>
|
||
<button class="tab-btn" data-tab="rf">RF / Signal</button>
|
||
<button class="tab-btn" data-tab="topology">Topology</button>
|
||
<button class="tab-btn" data-tab="channels">Channels</button>
|
||
<button class="tab-btn" data-tab="hashsizes">Hash Stats</button>
|
||
<button class="tab-btn" data-tab="collisions">Hash Collisions</button>
|
||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||
</div>
|
||
</div>
|
||
<div id="analyticsContent" class="analytics-content">
|
||
<div class="text-center text-muted" style="padding:40px">Loading analytics…</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Tab handling
|
||
const analyticsTabs = document.getElementById('analyticsTabs');
|
||
initTabBar(analyticsTabs);
|
||
analyticsTabs.addEventListener('click', e => {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
renderTab(btn.dataset.tab);
|
||
});
|
||
|
||
// Delegated click/keyboard handler for clickable table rows
|
||
const analyticsContent = document.getElementById('analyticsContent');
|
||
if (analyticsContent) {
|
||
const handler = (e) => {
|
||
const row = e.target.closest('tr[data-action="navigate"]');
|
||
if (!row) return;
|
||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||
if (e.type === 'keydown') e.preventDefault();
|
||
location.hash = row.dataset.value;
|
||
};
|
||
analyticsContent.addEventListener('click', handler);
|
||
analyticsContent.addEventListener('keydown', handler);
|
||
}
|
||
|
||
try {
|
||
_analyticsData = {};
|
||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||
api('/analytics/hash-sizes'),
|
||
api('/analytics/rf'),
|
||
api('/analytics/topology'),
|
||
api('/analytics/channels'),
|
||
]);
|
||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||
renderTab('overview');
|
||
} catch (e) {
|
||
document.getElementById('analyticsContent').innerHTML =
|
||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function renderTab(tab) {
|
||
const el = document.getElementById('analyticsContent');
|
||
const d = _analyticsData;
|
||
switch (tab) {
|
||
case 'overview': renderOverview(el, d); break;
|
||
case 'rf': renderRF(el, d.rfData); break;
|
||
case 'topology': renderTopology(el, d.topoData); break;
|
||
case 'channels': renderChannels(el, d.chanData); break;
|
||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||
case 'subpaths': await renderSubpaths(el); break;
|
||
case 'nodes': await renderNodesTab(el); break;
|
||
}
|
||
// Auto-apply column resizing to all analytics tables
|
||
requestAnimationFrame(() => {
|
||
el.querySelectorAll('.analytics-table').forEach((tbl, i) => {
|
||
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
|
||
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ===================== OVERVIEW =====================
|
||
function renderOverview(el, d) {
|
||
const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData;
|
||
el.innerHTML = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||
<div class="stat-label">Total Packets</div>
|
||
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${topo.uniqueNodes}</div>
|
||
<div class="stat-label">Unique Nodes</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.snr.avg.toFixed(1)} dB</div>
|
||
<div class="stat-label">Avg SNR</div>
|
||
<div class="stat-detail">${rf.snr.min.toFixed(1)} to ${rf.snr.max.toFixed(1)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.rssi.avg.toFixed(0)} dBm</div>
|
||
<div class="stat-label">Avg RSSI</div>
|
||
<div class="stat-detail">${rf.rssi.min} to ${rf.rssi.max}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${topo.avgHops.toFixed(1)}</div>
|
||
<div class="stat-label">Avg Hops</div>
|
||
<div class="stat-detail">max ${topo.maxHops}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${ch.activeChannels}</div>
|
||
<div class="stat-label">Active Channels</div>
|
||
<div class="stat-detail">${ch.decryptable} decryptable</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.avgPacketSize} B</div>
|
||
<div class="stat-label">Avg Packet Size</div>
|
||
<div class="stat-detail">${rf.minPacketSize}–${rf.maxPacketSize} B</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${((rf.timeSpanHours || 1)).toFixed(1)}h</div>
|
||
<div class="stat-label">Data Span</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Packets / Hour</h3>
|
||
${barChart(rf.packetsPerHour.map(h=>h.count), rf.packetsPerHour.map(h=>h.hour.slice(11)+'h'), 'var(--accent)')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📦 Payload Type Mix</h3>
|
||
${renderPayloadPie(rf.payloadTypes)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>🔗 Hop Count Distribution</h3>
|
||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPayloadPie(types) {
|
||
const total = types.reduce((s, t) => s + t.count, 0);
|
||
const colors = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16'];
|
||
let html = '<div class="payload-bars">';
|
||
types.forEach((t, i) => {
|
||
const pct = (t.count / total * 100).toFixed(1);
|
||
const w = Math.max(t.count / total * 100, 1);
|
||
html += `<div class="payload-bar-row">
|
||
<div class="payload-bar-label"><span class="legend-dot" style="background:${colors[i]}"></span>${t.name}</div>
|
||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${w}%;background:${colors[i]}"></div></div>
|
||
<div class="payload-bar-value">${t.count} <span class="text-muted">(${pct}%)</span></div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== RF / SIGNAL =====================
|
||
function renderRF(el, rf) {
|
||
const snrHist = histogram(rf.snrValues, 20, '#22c55e');
|
||
const rssiHist = histogram(rf.rssiValues, 20, '#3b82f6');
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📶 SNR Distribution</h3>
|
||
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
|
||
${snrHist.svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${rf.snr.min.toFixed(1)} dB</strong></span>
|
||
<span>Mean: <strong>${rf.snr.avg.toFixed(1)} dB</strong></span>
|
||
<span>Median: <strong>${rf.snr.median.toFixed(1)} dB</strong></span>
|
||
<span>Max: <strong>${rf.snr.max.toFixed(1)} dB</strong></span>
|
||
<span>σ: <strong>${rf.snr.stddev.toFixed(1)} dB</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📡 RSSI Distribution</h3>
|
||
<p class="text-muted">Received Signal Strength (closer to 0 = stronger)</p>
|
||
${rssiHist.svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${rf.rssi.min} dBm</strong></span>
|
||
<span>Mean: <strong>${rf.rssi.avg.toFixed(0)} dBm</strong></span>
|
||
<span>Median: <strong>${rf.rssi.median} dBm</strong></span>
|
||
<span>Max: <strong>${rf.rssi.max} dBm</strong></span>
|
||
<span>σ: <strong>${rf.rssi.stddev.toFixed(1)} dBm</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🎯 SNR vs RSSI Scatter</h3>
|
||
<p class="text-muted">Each dot = one packet. Cluster position reveals link quality.</p>
|
||
${renderScatter(rf.scatterData)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📊 SNR by Payload Type</h3>
|
||
${renderSNRByType(rf.snrByType)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Signal Quality Over Time</h3>
|
||
${renderSignalTimeline(rf.signalOverTime)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>📏 Packet Size Distribution</h3>
|
||
<p class="text-muted">Raw packet length in bytes</p>
|
||
${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${rf.minPacketSize} B</strong></span>
|
||
<span>Avg: <strong>${rf.avgPacketSize} B</strong></span>
|
||
<span>Max: <strong>${rf.maxPacketSize} B</strong></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderScatter(data) {
|
||
const w = 600, h = 300, pad = 40;
|
||
const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5;
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:300px" role="img" aria-label="SNR vs RSSI scatter plot showing signal quality distribution"><title>SNR vs RSSI scatter plot showing signal quality distribution</title>`;
|
||
// Axes
|
||
svg += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||
svg += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||
svg += `<text x="${w/2}" y="${h-5}" text-anchor="middle" font-size="11" fill="var(--text-muted)">SNR (dB)</text>`;
|
||
svg += `<text x="12" y="${h/2}" text-anchor="middle" font-size="11" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">RSSI (dBm)</text>`;
|
||
// Grid labels
|
||
for (let snr = -10; snr <= 14; snr += 4) {
|
||
const x = pad + (snr - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${snr}</text>`;
|
||
}
|
||
for (let rssi = -120; rssi <= -20; rssi += 20) {
|
||
const y = h - pad - (rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<text x="${pad-4}" y="${y+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${rssi}</text>`;
|
||
}
|
||
// Quality zones
|
||
const zones = [
|
||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: '#22c55e20' },
|
||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: '#f59e0b15' },
|
||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: '#ef444410' },
|
||
];
|
||
// Define patterns for color-blind accessibility
|
||
svg += `<defs>`;
|
||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="#22c55e" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="#f59e0b" opacity="0.4"/></pattern>`;
|
||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||
svg += `</defs>`;
|
||
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
|
||
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
|
||
const zoneBorder = { 'Excellent': '#22c55e', 'Good': '#f59e0b', 'Weak': '#ef4444' };
|
||
zones.forEach(z => {
|
||
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const y1 = h - pad - (z.rssi[1] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
const y2 = h - pad - (z.rssi[0] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="${z.color}"/>`;
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="url(#${zonePatterns[z.label]})"/>`;
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="none" stroke="${zoneBorder[z.label]}" stroke-width="1" stroke-dasharray="${zoneDash[z.label]}" opacity="0.6"/>`;
|
||
svg += `<text x="${x1+4}" y="${y1+12}" font-size="9" fill="var(--text-muted)" opacity="0.7">${z.label}</text>`;
|
||
});
|
||
// Dots (sample if too many)
|
||
const sample = data.length > 500 ? data.filter((_, i) => i % Math.ceil(data.length / 500) === 0) : data;
|
||
sample.forEach(d => {
|
||
const x = pad + (d.snr - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const y = h - pad - (d.rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<circle cx="${x}" cy="${y}" r="2" fill="var(--accent)" opacity="0.5"/>`;
|
||
});
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function renderSNRByType(snrByType) {
|
||
if (!snrByType.length) return '<div class="text-muted">No data</div>';
|
||
let html = '<table class="analytics-table"><thead><tr><th>Type</th><th>Packets</th><th>Avg SNR</th><th>Min</th><th>Max</th><th>Distribution</th></tr></thead><tbody>';
|
||
snrByType.forEach(t => {
|
||
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
|
||
const color = t.avg > 6 ? '#22c55e' : t.avg > 0 ? '#f59e0b' : '#ef4444';
|
||
html += `<tr>
|
||
<td><strong>${t.name}</strong></td>
|
||
<td>${t.count}</td>
|
||
<td><strong>${t.avg.toFixed(1)} dB</strong></td>
|
||
<td>${t.min.toFixed(1)}</td>
|
||
<td>${t.max.toFixed(1)}</td>
|
||
<td><div class="hash-bar-track" style="height:14px"><div class="hash-bar-fill" style="width:${barPct}%;background:${color};height:100%"></div></div></td>
|
||
</tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderSignalTimeline(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
const w = 400, h = 160, pad = 35;
|
||
const maxPkts = Math.max(...data.map(d => d.count), 1);
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Signal quality over time showing SNR trend and packet volume"><title>Signal quality over time showing SNR trend and packet volume</title>`;
|
||
const snrPts = data.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
svg += `<polyline points="${snrPts}" fill="none" stroke="#22c55e" stroke-width="2"/>`;
|
||
// Packet count as area
|
||
const areaPts = data.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
const y = h - pad - (d.count / maxPkts) * (h - pad * 2) * 0.4;
|
||
return `${x},${y}`;
|
||
});
|
||
const baseline = data.map((_, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
return `${x},${h - pad}`;
|
||
});
|
||
svg += `<polygon points="${areaPts.join(' ')} ${baseline.reverse().join(' ')}" fill="var(--accent)" opacity="0.15"/>`;
|
||
// Labels
|
||
const step = Math.max(1, Math.floor(data.length / 6));
|
||
for (let i = 0; i < data.length; i += step) {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${data[i].hour.slice(11)}h</text>`;
|
||
}
|
||
svg += '</svg>';
|
||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#22c55e"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||
return svg;
|
||
}
|
||
|
||
// ===================== TOPOLOGY =====================
|
||
function renderTopology(el, topo) {
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🔗 Hop Count Distribution</h3>
|
||
<p class="text-muted">Number of repeater hops per packet</p>
|
||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||
<div class="rf-stats">
|
||
<span>Avg: <strong>${topo.avgHops.toFixed(1)} hops</strong></span>
|
||
<span>Median: <strong>${topo.medianHops}</strong></span>
|
||
<span>Max: <strong>${topo.maxHops}</strong></span>
|
||
<span>1-hop direct: <strong>${topo.hopDistribution[0]?.count || 0}</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>🕸️ Top Repeaters</h3>
|
||
<p class="text-muted">Nodes appearing most in packet paths</p>
|
||
${renderRepeaterTable(topo.topRepeaters)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🤝 Repeater Pair Heatmap</h3>
|
||
<p class="text-muted">Which repeaters frequently appear together in paths</p>
|
||
${renderPairTable(topo.topPairs)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📊 Hops vs SNR</h3>
|
||
<p class="text-muted">Does more hops = worse signal?</p>
|
||
${renderHopsSNR(topo.hopsVsSnr)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>🏆 Best Path to Each Node</h3>
|
||
<p class="text-muted">Shortest hop distance seen across all observers</p>
|
||
${renderBestPath(topo.bestPathList)}
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>🌐 Per-Observer Reachability</h3>
|
||
<p class="text-muted">Nodes at each hop distance, from each observer's perspective</p>
|
||
${topo.observers.length > 1 ? `<div class="observer-selector" id="obsSelector">
|
||
${topo.observers.map((o, i) => `<button class="tab-btn ${i === 0 ? 'active' : ''}" data-obs="${o.id}">${esc(o.name)}</button>`).join('')}
|
||
<button class="tab-btn" data-obs="__all">All Observers</button>
|
||
</div>` : ''}
|
||
<div id="reachContent">${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}</div>
|
||
</div>
|
||
|
||
${topo.multiObsNodes.length ? `<div class="analytics-card">
|
||
<h3>🔀 Cross-Observer Comparison</h3>
|
||
<p class="text-muted">Nodes seen by multiple observers — hop distance varies by vantage point</p>
|
||
${renderCrossObserver(topo.multiObsNodes)}
|
||
</div>` : ''}
|
||
`;
|
||
|
||
// Observer selector event handling
|
||
const selector = document.getElementById('obsSelector');
|
||
if (selector) {
|
||
initTabBar(selector);
|
||
selector.addEventListener('click', e => {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
selector.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const obsId = btn.dataset.obs;
|
||
document.getElementById('reachContent').innerHTML =
|
||
obsId === '__all' ? renderAllObserversReach(topo.perObserverReach) : renderPerObserverReach(topo.perObserverReach, obsId);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderRepeaterTable(repeaters) {
|
||
if (!repeaters.length) return '<div class="text-muted">No data</div>';
|
||
const max = repeaters[0].count;
|
||
let html = '<div class="repeater-list">';
|
||
repeaters.slice(0, 15).forEach(r => {
|
||
const pct = (r.count / max * 100).toFixed(0);
|
||
html += `<div class="repeater-row ${r.pubkey ? 'clickable-row' : ''}" ${r.pubkey ? `onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'"` : ''}>
|
||
<div class="repeater-name">${r.name ? '<strong>' + esc(r.name) + '</strong>' : '<span class="mono">' + r.hop + '</span>'}</div>
|
||
<div class="repeater-bar"><div class="hash-bar-track"><div class="hash-bar-fill" style="width:${pct}%;background:var(--accent)"></div></div></div>
|
||
<div class="repeater-count">${r.count.toLocaleString()}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
function renderPairTable(pairs) {
|
||
if (!pairs.length) return '<div class="text-muted">Not enough multi-hop data</div>';
|
||
let html = '<table class="analytics-table"><thead><tr><th>Node A</th><th>Node B</th><th>Co-appearances</th></tr></thead><tbody>';
|
||
pairs.slice(0, 12).forEach(p => {
|
||
html += `<tr>
|
||
<td>${p.nameA ? `<a href="#/nodes/${encodeURIComponent(p.pubkeyA)}" class="analytics-link">${esc(p.nameA)}</a>` : `<span class="mono">${p.hopA}</span>`}</td>
|
||
<td>${p.nameB ? `<a href="#/nodes/${encodeURIComponent(p.pubkeyB)}" class="analytics-link">${esc(p.nameB)}</a>` : `<span class="mono">${p.hopB}</span>`}</td>
|
||
<td>${p.count}</td>
|
||
</tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderHopsSNR(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
const w = 380, h = 160, pad = 40;
|
||
const maxHop = Math.max(...data.map(d => d.hops));
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Hops vs SNR bubble chart showing signal degradation over distance"><title>Hops vs SNR bubble chart showing signal degradation over distance</title>`;
|
||
data.forEach(d => {
|
||
const x = pad + (d.hops / maxHop) * (w - pad * 2);
|
||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
|
||
const color = d.avgSnr > 6 ? '#22c55e' : d.avgSnr > 0 ? '#f59e0b' : '#ef4444';
|
||
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"/>`;
|
||
svg += `<text x="${x}" y="${y-r-3}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${d.hops}h</text>`;
|
||
});
|
||
svg += `<text x="${w/2}" y="${h-5}" text-anchor="middle" font-size="10" fill="var(--text-muted)">Hops</text>`;
|
||
svg += `<text x="10" y="${h/2}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,10,${h/2})">Avg SNR</text>`;
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function renderPerObserverReach(perObserverReach, obsId) {
|
||
const data = perObserverReach[obsId];
|
||
if (!data || !data.rings.length) return '<div class="text-muted">No path data for this observer</div>';
|
||
let html = `<div class="reach-rings">`;
|
||
data.rings.forEach(ring => {
|
||
const opacity = Math.max(0.3, 1 - ring.hops * 0.06);
|
||
const nodeLinks = ring.nodes.slice(0, 8).map(n => {
|
||
const label = n.name ? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>` : `<span class="mono">${n.hop}</span>`;
|
||
const detail = n.distRange ? ` <span class="text-muted">(${n.distRange})</span>` : '';
|
||
return label + detail;
|
||
}).join(', ');
|
||
const extra = ring.nodes.length > 8 ? ` <span class="text-muted">+${ring.nodes.length - 8} more</span>` : '';
|
||
html += `<div class="reach-ring" style="opacity:${opacity}">
|
||
<div class="reach-hop">${ring.hops} hop${ring.hops > 1 ? 's' : ''}</div>
|
||
<div class="reach-nodes">${nodeLinks}${extra}</div>
|
||
<div class="reach-count">${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
function renderAllObserversReach(perObserverReach) {
|
||
let html = '';
|
||
for (const [obsId, data] of Object.entries(perObserverReach)) {
|
||
html += `<h4 style="margin:12px 0 6px">📡 ${esc(data.observer_name)}</h4>`;
|
||
html += renderPerObserverReach(perObserverReach, obsId);
|
||
}
|
||
return html || '<div class="text-muted">No data</div>';
|
||
}
|
||
|
||
function renderCrossObserver(nodes) {
|
||
if (!nodes.length) return '<div class="text-muted">No nodes seen by multiple observers</div>';
|
||
let html = `<table class="analytics-table">
|
||
<thead><tr><th>Node</th><th>Observers</th><th>Hop Distances</th></tr></thead><tbody>`;
|
||
nodes.forEach(n => {
|
||
const name = n.name
|
||
? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>`
|
||
: `<span class="mono">${n.hop}</span>`;
|
||
const obsInfo = n.observers.map(o =>
|
||
`${esc(o.observer_name)}: <strong>${o.minDist} hop${o.minDist > 1 ? 's' : ''}</strong> <span class="text-muted">(${o.count} pkts)</span>`
|
||
).join('<br>');
|
||
html += `<tr><td>${name}</td><td>${n.observers.length}</td><td>${obsInfo}</td></tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderBestPath(nodes) {
|
||
if (!nodes.length) return '<div class="text-muted">No data</div>';
|
||
// Group by distance for a cleaner view
|
||
const byDist = {};
|
||
nodes.forEach(n => {
|
||
if (!byDist[n.minDist]) byDist[n.minDist] = [];
|
||
byDist[n.minDist].push(n);
|
||
});
|
||
let html = '<div class="reach-rings">';
|
||
Object.entries(byDist).sort((a, b) => +a[0] - +b[0]).forEach(([dist, nodes]) => {
|
||
const opacity = Math.max(0.3, 1 - (+dist) * 0.06);
|
||
const nodeLinks = nodes.slice(0, 10).map(n => {
|
||
const label = n.name
|
||
? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>`
|
||
: `<span class="mono">${n.hop}</span>`;
|
||
return label + ` <span class="text-muted">via ${esc(n.observer_name)}</span>`;
|
||
}).join(', ');
|
||
const extra = nodes.length > 10 ? ` <span class="text-muted">+${nodes.length - 10} more</span>` : '';
|
||
html += `<div class="reach-ring" style="opacity:${opacity}">
|
||
<div class="reach-hop">${dist} hop${+dist > 1 ? 's' : ''}</div>
|
||
<div class="reach-nodes">${nodeLinks}${extra}</div>
|
||
<div class="reach-count">${nodes.length} node${nodes.length > 1 ? 's' : ''}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== CHANNELS =====================
|
||
function renderChannels(el, ch) {
|
||
el.innerHTML = `
|
||
<div class="analytics-card">
|
||
<h3>📻 Channel Activity</h3>
|
||
<p class="text-muted">${ch.activeChannels} active channels, ${ch.decryptable} decryptable</p>
|
||
<table class="analytics-table">
|
||
<thead><tr><th>Channel</th><th>Hash</th><th>Messages</th><th>Unique Senders</th><th>Last Activity</th><th>Decrypted</th></tr></thead>
|
||
<tbody>
|
||
${ch.channels.map(c => `<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=${c.hash}" tabindex="0" role="row">
|
||
<td><strong>${esc(c.name || 'Unknown')}</strong></td>
|
||
<td class="mono">${c.hash}</td>
|
||
<td>${c.messages}</td>
|
||
<td>${c.senders}</td>
|
||
<td>${timeAgo(c.lastActivity)}</td>
|
||
<td>${c.encrypted ? '🔒' : '✅'}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>💬 Messages / Hour by Channel</h3>
|
||
${renderChannelTimeline(ch.channelTimeline)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>🗣️ Top Senders</h3>
|
||
${renderTopSenders(ch.topSenders)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>📊 Message Length Distribution</h3>
|
||
${ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '<div class="text-muted">No decrypted messages</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderChannelTimeline(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
const hours = [...new Set(data.map(d => d.hour))].sort();
|
||
const channels = [...new Set(data.map(d => d.channel))];
|
||
const colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b'];
|
||
const w = 600, h = 180, pad = 35;
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px" role="img" aria-label="Channel message activity over time"><title>Channel message activity over time</title>`;
|
||
channels.forEach((ch, ci) => {
|
||
const pts = hours.map((hr, i) => {
|
||
const entry = data.find(d => d.hour === hr && d.channel === ch);
|
||
const count = entry ? entry.count : 0;
|
||
const max = Math.max(...data.map(d => d.count), 1);
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hours.length - 1, 1));
|
||
const y = h - pad - (count / max) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
svg += `<polyline points="${pts}" fill="none" stroke="${colors[ci % colors.length]}" stroke-width="1.5" opacity="0.8"/>`;
|
||
});
|
||
const step = Math.max(1, Math.floor(hours.length / 6));
|
||
for (let i = 0; i < hours.length; i += step) {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hours.length - 1, 1));
|
||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${hours[i].slice(11)}h</text>`;
|
||
}
|
||
svg += '</svg>';
|
||
svg += `<div class="timeline-legend">${channels.map((ch, i) => `<span><span class="legend-dot" style="background:${colors[i % colors.length]}"></span>${esc(ch)}</span>`).join('')}</div>`;
|
||
return svg;
|
||
}
|
||
|
||
function renderTopSenders(senders) {
|
||
if (!senders.length) return '<div class="text-muted">No decrypted messages</div>';
|
||
const max = senders[0].count;
|
||
let html = '<div class="repeater-list">';
|
||
senders.slice(0, 10).forEach(s => {
|
||
html += `<div class="repeater-row">
|
||
<div class="repeater-name"><strong>${esc(s.name)}</strong></div>
|
||
<div class="repeater-bar"><div class="hash-bar-track"><div class="hash-bar-fill" style="width:${(s.count/max*100).toFixed(0)}%;background:#8b5cf6"></div></div></div>
|
||
<div class="repeater-count">${s.count} msgs</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== HASH SIZES (original) =====================
|
||
function renderHashSizes(el, data) {
|
||
const d = data.distribution;
|
||
const total = data.total;
|
||
const pct = (n) => total ? (n / total * 100).toFixed(1) : '0';
|
||
const maxCount = Math.max(d[1] || 0, d[2] || 0, d[3] || 0, 1);
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>Hash Size Distribution</h3>
|
||
<p class="text-muted">${total.toLocaleString()} packets with path hops</p>
|
||
<div class="hash-bars">
|
||
${[1, 2, 3].map(size => {
|
||
const count = d[size] || 0;
|
||
const width = Math.max((count / maxCount) * 100, count ? 2 : 0);
|
||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||
return `<div class="hash-bar-row">
|
||
<div class="hash-bar-label"><strong>${size}-byte</strong> <span class="text-muted">(${size * 8}-bit, ${Math.pow(256, size).toLocaleString()} IDs)</span></div>
|
||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]}"></div></div>
|
||
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${pct(count)}%)</span></div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Hash Size Over Time</h3>
|
||
${renderHashTimeline(data.hourly)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>Multi-Byte Hash Adopters</h3>
|
||
<p class="text-muted">Nodes advertising with 2+ byte hash paths</p>
|
||
${data.multiByteNodes.length ? `
|
||
<table class="analytics-table">
|
||
<thead><tr><th>Node</th><th>Hash Size</th><th>Adverts</th><th>Last Seen</th></tr></thead>
|
||
<tbody>
|
||
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
|
||
<td><strong>${esc(n.name)}</strong></td>
|
||
<td><span class="badge badge-hash-${n.hashSize}">${n.hashSize}-byte</span></td>
|
||
<td>${n.packets}</td>
|
||
<td>${timeAgo(n.lastSeen)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
` : '<div class="text-muted" style="padding:16px">No multi-byte adopters found</div>'}
|
||
</div>
|
||
|
||
<div class="analytics-card flex-1">
|
||
<h3>Top Path Hops</h3>
|
||
<table class="analytics-table">
|
||
<thead><tr><th>Hop</th><th>Node</th><th>Bytes</th><th>Appearances</th></tr></thead>
|
||
<tbody>
|
||
${data.topHops.map(h => {
|
||
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
|
||
return `<tr class="clickable-row" data-action="navigate" data-value="${link}" tabindex="0" role="row">
|
||
<td class="mono">${h.hex}</td>
|
||
<td>${h.name ? `<strong>${esc(h.name)}</strong>` : '<span class="text-muted">unknown</span>'}</td>
|
||
<td><span class="badge badge-hash-${h.size}">${h.size}-byte</span></td>
|
||
<td>${h.count.toLocaleString()}</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderCollisionTab(el, data) {
|
||
el.innerHTML = `
|
||
<div class="analytics-card">
|
||
<h3>1-Byte Hash Usage Matrix</h3>
|
||
<p class="text-muted" style="margin:0 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
|
||
<div id="hashMatrix"></div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>1-Byte Collision Risk</h3>
|
||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||
</div>
|
||
`;
|
||
let allNodes = [];
|
||
try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {}
|
||
renderHashMatrix(data.topHops, allNodes);
|
||
renderCollisions(data.topHops, allNodes);
|
||
}
|
||
|
||
function renderHashTimeline(hourly) {
|
||
if (!hourly.length) return '<div class="text-muted">Not enough data</div>';
|
||
const w = 800, h = 180, pad = 35;
|
||
const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1);
|
||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px" role="img" aria-label="Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends"><title>Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends</title>`;
|
||
for (const size of [1, 2, 3]) {
|
||
const pts = hourly.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
|
||
const y = h - pad - ((d[size] || 0) / maxVal) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
if (hourly.some(d => d[size] > 0)) svg += `<polyline points="${pts}" fill="none" stroke="${colors[size]}" stroke-width="2"/>`;
|
||
}
|
||
const step = Math.max(1, Math.floor(hourly.length / 8));
|
||
for (let i = 0; i < hourly.length; i += step) {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
|
||
svg += `<text x="${x}" y="${h-5}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${hourly[i].hour.slice(11)}h</text>`;
|
||
}
|
||
svg += '</svg>';
|
||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#ef4444"></span>1-byte</span><span><span class="legend-dot" style="background:#22c55e"></span>2-byte</span><span><span class="legend-dot" style="background:#3b82f6"></span>3-byte</span></div>`;
|
||
return svg;
|
||
}
|
||
|
||
async function renderHashMatrix(topHops, allNodes) {
|
||
const el = document.getElementById('hashMatrix');
|
||
|
||
// Build prefix → node count map
|
||
const prefixNodes = {};
|
||
for (let i = 0; i < 256; i++) {
|
||
const hex = i.toString(16).padStart(2, '0').toUpperCase();
|
||
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
|
||
}
|
||
|
||
const nibbles = '0123456789ABCDEF'.split('');
|
||
const cellSize = 36;
|
||
const headerSize = 24;
|
||
|
||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||
for (const n of nibbles) {
|
||
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||
}
|
||
html += '</tr>';
|
||
|
||
for (let hi = 0; hi < 16; hi++) {
|
||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||
for (let lo = 0; lo < 16; lo++) {
|
||
const hex = nibbles[hi] + nibbles[lo];
|
||
const nodes = prefixNodes[hex] || [];
|
||
const count = nodes.length;
|
||
let bg, color;
|
||
if (count === 0) {
|
||
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
|
||
} else if (count === 1) {
|
||
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
|
||
} else {
|
||
// 2+ nodes: orange→red
|
||
const t = Math.min((count - 2) / 4, 1);
|
||
const r = Math.round(220 + 35 * t);
|
||
const g = Math.round(120 * (1 - t));
|
||
bg = `rgb(${r},${g},30)`; color = '#fff';
|
||
}
|
||
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
|
||
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
|
||
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</table></div>';
|
||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
|
||
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 — Available</span>
|
||
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 — One node</span>
|
||
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 — Two nodes (collision)</span>
|
||
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ — Three+ nodes (collision)</span>
|
||
</div>`;
|
||
el.innerHTML = html;
|
||
|
||
// Click handler for cells
|
||
el.querySelectorAll('.hash-active').forEach(td => {
|
||
td.addEventListener('click', () => {
|
||
const hex = td.dataset.hex.toUpperCase();
|
||
const matches = prefixNodes[hex] || [];
|
||
const detail = document.getElementById('hashDetail');
|
||
if (!matches.length) {
|
||
detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`;
|
||
return;
|
||
}
|
||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||
`<div style="margin-top:8px">${matches.map(m => {
|
||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||
? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||
: '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||
}).join('')}</div>`;
|
||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||
td.classList.add('hash-selected');
|
||
});
|
||
});
|
||
}
|
||
|
||
async function renderCollisions(topHops, allNodes) {
|
||
const el = document.getElementById('collisionList');
|
||
const oneByteHops = topHops.filter(h => h.size === 1);
|
||
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
|
||
try {
|
||
const nodes = allNodes;
|
||
const collisions = [];
|
||
for (const hop of oneByteHops) {
|
||
const prefix = hop.hex.toLowerCase();
|
||
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
|
||
if (matches.length > 1) {
|
||
// Calculate pairwise distances for classification
|
||
const withCoords = matches.filter(m => m.lat && m.lon && !(m.lat === 0 && m.lon === 0));
|
||
let maxDistKm = 0;
|
||
let classification = 'unknown';
|
||
if (withCoords.length >= 2) {
|
||
for (let i = 0; i < withCoords.length; i++) {
|
||
for (let j = i + 1; j < withCoords.length; j++) {
|
||
const dLat = (withCoords[i].lat - withCoords[j].lat) * 111;
|
||
const dLon = (withCoords[i].lon - withCoords[j].lon) * 85;
|
||
const d = Math.sqrt(dLat * dLat + dLon * dLon);
|
||
if (d > maxDistKm) maxDistKm = d;
|
||
}
|
||
}
|
||
if (maxDistKm < 50) classification = 'local';
|
||
else if (maxDistKm < 200) classification = 'regional';
|
||
else classification = 'distant';
|
||
} else if (withCoords.length < 2) {
|
||
classification = 'incomplete';
|
||
}
|
||
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
|
||
}
|
||
}
|
||
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
|
||
|
||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||
|
||
el.innerHTML = `<table class="analytics-table">
|
||
<thead><tr><th>Hop</th><th>Appearances</th><th>Max Distance</th><th>Assessment</th><th>Colliding Nodes</th></tr></thead>
|
||
<tbody>${collisions.map(c => {
|
||
let badge, tooltip;
|
||
if (c.classification === 'local') {
|
||
badge = '<span class="badge" style="background:#22c55e;color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||
} else if (c.classification === 'regional') {
|
||
badge = '<span class="badge" style="background:#f59e0b;color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||
} else if (c.classification === 'distant') {
|
||
badge = '<span class="badge" style="background:#ef4444;color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||
} else {
|
||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||
tooltip = 'Not enough coordinate data to classify';
|
||
}
|
||
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
|
||
return `<tr>
|
||
<td class="mono">${c.hop}</td>
|
||
<td>${c.count.toLocaleString()}</td>
|
||
<td>${distStr}</td>
|
||
<td title="${tooltip}">${badge}</td>
|
||
<td>${c.matches.map(m => {
|
||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||
}).join('<br>')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||
</div>`;
|
||
} catch { el.innerHTML = '<div class="text-muted">Failed to load</div>'; }
|
||
}
|
||
|
||
async function renderSubpaths(el) {
|
||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||
try {
|
||
const [d2, d3, d4, d5] = await Promise.all([
|
||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
|
||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
|
||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
|
||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
|
||
]);
|
||
|
||
function renderTable(data, title) {
|
||
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
|
||
const maxCount = data.subpaths[0]?.count || 1;
|
||
return `<h4>${title}</h4>
|
||
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
|
||
<table class="analytics-table"><thead><tr>
|
||
<th>#</th><th>Route</th><th>Occurrences</th><th>% of paths</th><th>Frequency</th>
|
||
</tr></thead><tbody>
|
||
${data.subpaths.map((s, i) => {
|
||
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
|
||
const hops = s.path.split(' → ');
|
||
const rawHops = s.rawHops || [];
|
||
const hasSelfLoop = hops.some((h, j) => j > 0 && h === hops[j - 1]);
|
||
const routeDisplay = hops.map(h => esc(h)).join(' → ');
|
||
const prefixDisplay = rawHops.join(' → ');
|
||
return `<tr data-hops="${esc(rawHops.join(','))}" ${hasSelfLoop ? 'class="subpath-selfloop"' : ''} style="cursor:pointer">
|
||
<td>${i + 1}</td>
|
||
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
|
||
<td>${s.count.toLocaleString()}</td>
|
||
<td>${s.pct}%</td>
|
||
<td><div style="background:${hasSelfLoop ? '#f59e0b' : 'var(--accent,#3b82f6)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="subpath-layout">
|
||
<div class="subpath-list" id="subpathList">
|
||
<h3>🛤️ Route Pattern Analysis</h3>
|
||
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
|
||
<label style="display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;font-size:0.9em">
|
||
<input type="checkbox" id="hideCollisions" aria-label="Hide likely prefix collisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
|
||
</label>
|
||
<div class="subpath-jump-nav">
|
||
<span>Jump to:</span>
|
||
<a href="#sp-pairs">Pairs</a>
|
||
<a href="#sp-triples">Triples</a>
|
||
<a href="#sp-quads">Quads</a>
|
||
<a href="#sp-long">5+ hops</a>
|
||
</div>
|
||
<div id="sp-pairs">${renderTable(d2, 'Pairs (2-hop links)')}</div>
|
||
<div id="sp-triples">${renderTable(d3, 'Triples (3-hop chains)')}</div>
|
||
<div id="sp-quads">${renderTable(d4, 'Quads (4-hop chains)')}</div>
|
||
<div id="sp-long">${renderTable(d5, 'Long chains (5+ hops)')}</div>
|
||
</div>
|
||
<div class="subpath-detail collapsed" id="subpathDetail">
|
||
<div class="text-muted" style="padding:40px;text-align:center">Select a route to view details</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Click handler for rows
|
||
el.addEventListener('click', e => {
|
||
const tr = e.target.closest('tr[data-hops]');
|
||
if (!tr) return;
|
||
el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected'));
|
||
tr.classList.add('subpath-selected');
|
||
loadSubpathDetail(tr.dataset.hops);
|
||
});
|
||
|
||
// Jump nav — scroll within list panel
|
||
el.querySelectorAll('.subpath-jump-nav a').forEach(a => {
|
||
a.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
const target = document.getElementById(a.getAttribute('href').slice(1));
|
||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
});
|
||
|
||
// Collision toggle
|
||
const toggle = document.getElementById('hideCollisions');
|
||
function applyCollisionFilter() {
|
||
const hide = toggle.checked;
|
||
localStorage.setItem('subpath-hide-collisions', hide ? '1' : '0');
|
||
el.querySelectorAll('tr.subpath-selfloop').forEach(r => r.style.display = hide ? 'none' : '');
|
||
}
|
||
toggle.addEventListener('change', applyCollisionFilter);
|
||
applyCollisionFilter();
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="text-muted">Error loading subpath data: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadSubpathDetail(hopsStr) {
|
||
const panel = document.getElementById('subpathDetail');
|
||
panel.classList.remove('collapsed');
|
||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||
try {
|
||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr));
|
||
renderSubpathDetail(panel, data);
|
||
} catch (e) {
|
||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderSubpathDetail(panel, data) {
|
||
const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0));
|
||
const hasMap = nodesWithLoc.length >= 2;
|
||
const maxHour = Math.max(...data.hourDistribution, 1);
|
||
|
||
panel.innerHTML = `
|
||
<div class="subpath-detail-inner">
|
||
<h4>${data.nodes.map(n => esc(n.name)).join(' → ')}</h4>
|
||
<div class="subpath-meta">
|
||
<span class="hop-prefix mono">${data.hops.join(' → ')}</span>
|
||
<span>${data.totalMatches.toLocaleString()} occurrences</span>
|
||
</div>
|
||
|
||
${nodesWithLoc.length >= 2 ? `<div class="subpath-section">
|
||
<h5>📏 Hop Distances</h5>
|
||
${(() => {
|
||
const dists = [];
|
||
let total = 0;
|
||
for (let i = 0; i < data.nodes.length - 1; i++) {
|
||
const a = data.nodes[i], b = data.nodes[i+1];
|
||
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
|
||
const dLat = (a.lat - b.lat) * 111;
|
||
const dLon = (a.lon - b.lon) * 85;
|
||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||
total += km;
|
||
const cls = km > 200 ? 'color:#ef4444;font-weight:bold' : km > 50 ? 'color:#f59e0b' : 'color:#22c55e';
|
||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||
} else {
|
||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||
}
|
||
}
|
||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}</strong></div>`);
|
||
return dists.join('');
|
||
})()}
|
||
</div>` : ''}
|
||
|
||
${hasMap ? '<div id="subpathMap" style="height:200px;border-radius:8px;margin:12px 0;border:1px solid var(--border,#e5e7eb)"></div>' : ''}
|
||
|
||
<div class="subpath-section">
|
||
<h5>📡 Observer Receive Signal</h5>
|
||
<p class="text-muted" style="font-size:0.8em;margin:0 0 4px">Last hop → observer only, not between nodes in the route</p>
|
||
${data.signal.avgSnr != null
|
||
? `<div>Avg SNR: <strong>${data.signal.avgSnr} dB</strong> · Avg RSSI: <strong>${data.signal.avgRssi} dBm</strong> · ${data.signal.samples} samples</div>`
|
||
: '<div class="text-muted">No signal data</div>'}
|
||
</div>
|
||
|
||
<div class="subpath-section">
|
||
<h5>🕐 Activity by Hour (UTC)</h5>
|
||
<div class="hour-chart">
|
||
${data.hourDistribution.map((c, h) => `<div class="hour-bar" title="${h}:00 UTC — ${c} packets" style="height:${Math.max(2, c / maxHour * 100)}%"></div>`).join('')}
|
||
</div>
|
||
<div class="hour-labels"><span>0</span><span>6</span><span>12</span><span>18</span><span>23</span></div>
|
||
</div>
|
||
|
||
<div class="subpath-section">
|
||
<h5>⏱️ Timeline</h5>
|
||
<div>First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : '—'}</div>
|
||
<div>Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : '—'}</div>
|
||
</div>
|
||
|
||
${data.observers.length ? `
|
||
<div class="subpath-section">
|
||
<h5>👁️ Observers</h5>
|
||
${data.observers.map(o => `<div>${esc(o.name)}: ${o.count}</div>`).join('')}
|
||
</div>` : ''}
|
||
|
||
${data.parentPaths.length ? `
|
||
<div class="subpath-section">
|
||
<h5>🔗 Full Paths Containing This Route</h5>
|
||
<div class="parent-paths">
|
||
${data.parentPaths.map(p => `<div class="parent-path"><span class="mono" style="font-size:0.85em">${esc(p.path)}</span> <span class="text-muted">×${p.count}</span></div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
</div>`;
|
||
|
||
// Render minimap
|
||
if (hasMap && typeof L !== 'undefined') {
|
||
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map);
|
||
|
||
const latlngs = [];
|
||
nodesWithLoc.forEach((n, i) => {
|
||
const ll = [n.lat, n.lon];
|
||
latlngs.push(ll);
|
||
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
|
||
L.circleMarker(ll, {
|
||
radius: isEnd ? 8 : 5,
|
||
color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||
fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||
fillOpacity: 0.9, weight: 2
|
||
}).bindTooltip(n.name, { permanent: false }).addTo(map);
|
||
});
|
||
|
||
L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
|
||
}
|
||
}
|
||
|
||
async function renderNodesTab(el) {
|
||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||
try {
|
||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||
api('/nodes?limit=200&sortBy=lastSeen'),
|
||
api('/nodes/bulk-health?limit=50'),
|
||
api('/nodes/network-status')
|
||
]);
|
||
const nodes = nodesResp.nodes || nodesResp;
|
||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||
|
||
// Map bulk health by pubkey
|
||
const healthMap = {};
|
||
bulkHealth.forEach(h => { healthMap[h.public_key] = h; });
|
||
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
|
||
|
||
// Compute rankings
|
||
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalPackets || 0) - (a.health.stats.totalPackets || 0));
|
||
const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr);
|
||
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
|
||
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
|
||
|
||
// Use server-computed status across ALL nodes
|
||
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
|
||
|
||
function nodeLink(n) {
|
||
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
|
||
}
|
||
function claimedBadge(n) {
|
||
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px">★ MINE</span>' : '';
|
||
}
|
||
|
||
const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' };
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-section">
|
||
<h3>🔍 Network Status</h3>
|
||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:#22c55e">${active}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:#eab308">${degraded}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:#ef4444">${silent}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700">${totalNodes}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">Total Nodes</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h3>📊 Role Breakdown</h3>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px">
|
||
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
|
||
const c = ROLE_COLORS[role] || '#6b7280';
|
||
return `<span class="badge" style="background:${c}20;color:${c};padding:6px 12px;font-size:13px">${role}: ${count}</span>`;
|
||
}).join('')}
|
||
</div>
|
||
|
||
${myKeys.size ? `<h3>⭐ My Claimed Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th>Node</th><th>Role</th><th>Packets</th><th>Avg SNR</th><th>Observers</th><th>Last Heard</th></tr></thead>
|
||
<tbody>
|
||
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
|
||
const s = n.health.stats;
|
||
return `<tr>
|
||
<td>${nodeLink(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${s.totalPackets || 0}</td>
|
||
<td>${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td>${s.lastHeard ? timeAgo(s.lastHeard) : '—'}</td>
|
||
</tr>`;
|
||
}).join('') || '<tr><td colspan="6" class="text-muted">No claimed nodes have health data</td></tr>'}
|
||
</tbody>
|
||
</table>` : ''}
|
||
|
||
<h3>🏆 Most Active Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Total Packets</th><th>Packets Today</th><th>Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byPackets.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.stats.totalPackets || 0}</td>
|
||
<td>${n.health.stats.packetsToday || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>📶 Best Signal Quality</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Avg SNR</th><th>Observers</th><th>Analytics</th></tr></thead>
|
||
<tbody>
|
||
${bySnr.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.stats.avgSnr.toFixed(1)} dB</td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>👀 Most Observed Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Observers</th><th>Avg SNR</th><th>Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byObservers.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td>${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>⏰ Recently Active</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th>Node</th><th>Role</th><th>Last Heard</th><th>Packets Today</th><th>Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byRecent.slice(0, 15).map(n => `<tr>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${timeAgo(n.health.stats.lastHeard)}</td>
|
||
<td>${n.health.stats.packetsToday || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) {
|
||
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load node analytics: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function destroy() { _analyticsData = {}; }
|
||
|
||
registerPage('analytics', { init, destroy });
|
||
})();
|