mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 14:29:59 +00:00
#210: Add role="img" aria-label to 9 Chart.js canvases in node-analytics.js and observer-detail.js with descriptive labels. #211: Add scope="col" to all <th> elements across analytics.js, audio-lab.js, compare.js, node-analytics.js, nodes.js, observer-detail.js, observers.js, and packets.js (40+ headers). #212: Add aria-label to packet filter input and time window select in packets.js. Add for/id associations to all customize.js inputs: branding, theme colors, node/type colors, heatmap sliders, onboarding fields, and export controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+26
-17
@@ -177,6 +177,15 @@
|
||||
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
|
||||
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||||
});
|
||||
// #206 — Wrap analytics tables in scroll containers on mobile
|
||||
el.querySelectorAll('.analytics-table').forEach(tbl => {
|
||||
if (!tbl.parentElement.classList.contains('analytics-table-scroll')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'analytics-table-scroll';
|
||||
tbl.parentElement.insertBefore(wrapper, tbl);
|
||||
wrapper.appendChild(tbl);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Deep-link scroll to section within tab
|
||||
const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section');
|
||||
@@ -395,7 +404,7 @@
|
||||
|
||||
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>';
|
||||
let html = '<table class="analytics-table"><thead><tr><th scope="col">Type</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Min</th><th scope="col">Max</th><th scope="col">Distribution</th></tr></thead><tbody>';
|
||||
snrByType.forEach(t => {
|
||||
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
|
||||
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
|
||||
@@ -535,7 +544,7 @@
|
||||
|
||||
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>';
|
||||
let html = '<table class="analytics-table"><thead><tr><th scope="col">Node A</th><th scope="col">Node B</th><th scope="col">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>
|
||||
@@ -598,7 +607,7 @@
|
||||
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>`;
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Observers</th><th scope="col">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>`
|
||||
@@ -719,7 +728,7 @@
|
||||
var ths = '';
|
||||
for (var i = 0; i < cols.length; i++) {
|
||||
var c = cols[i];
|
||||
ths += '<th class="sortable' + (c.key === activeCol ? ' sort-active' : '') + '" data-sort-col="' + c.key + '">' +
|
||||
ths += '<th scope="col" class="sortable' + (c.key === activeCol ? ' sort-active' : '') + '" data-sort-col="' + c.key + '">' +
|
||||
c.label + channelSortArrow(c.key, activeCol, dir) + '</th>';
|
||||
}
|
||||
return '<thead><tr>' + ths + '</tr></thead>';
|
||||
@@ -880,7 +889,7 @@
|
||||
<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>
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Hash Size</th><th scope="col">Adverts</th><th scope="col">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>
|
||||
@@ -896,7 +905,7 @@
|
||||
<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>
|
||||
<thead><tr><th scope="col">Hop</th><th scope="col">Node</th><th scope="col">Bytes</th><th scope="col">Appearances</th></tr></thead>
|
||||
<tbody>
|
||||
${data.topHops.map(h => {
|
||||
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
|
||||
@@ -952,7 +961,7 @@
|
||||
ihEl.innerHTML = '<div class="text-muted" style="padding:4px">✅ No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
|
||||
} else {
|
||||
ihEl.innerHTML = `<table class="analytics-table" style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;overflow:hidden">
|
||||
<thead><tr><th>Node</th><th>Role</th><th>Current Hash</th><th>Sizes Seen</th></tr></thead>
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Current Hash</th><th scope="col">Sizes Seen</th></tr></thead>
|
||||
<tbody>${inconsistent.map((n, i) => {
|
||||
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
|
||||
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
|
||||
@@ -1123,7 +1132,7 @@
|
||||
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>
|
||||
<thead><tr><th scope="col">Hop</th><th scope="col">Appearances</th><th scope="col">Max Distance</th><th scope="col">Assessment</th><th scope="col">Colliding Nodes</th></tr></thead>
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
@@ -1179,7 +1188,7 @@
|
||||
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>
|
||||
<th scope="col">#</th><th scope="col">Route</th><th scope="col">Occurrences</th><th scope="col">% of paths</th><th scope="col">Frequency</th>
|
||||
</tr></thead><tbody>
|
||||
${data.subpaths.map((s, i) => {
|
||||
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
|
||||
@@ -1434,7 +1443,7 @@
|
||||
|
||||
${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>
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Last Heard</th></tr></thead>
|
||||
<tbody>
|
||||
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
|
||||
const s = n.health.stats;
|
||||
@@ -1452,7 +1461,7 @@
|
||||
|
||||
<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>
|
||||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Total Packets</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byPackets.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
@@ -1467,7 +1476,7 @@
|
||||
|
||||
<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>
|
||||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${bySnr.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
@@ -1482,7 +1491,7 @@
|
||||
|
||||
<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>
|
||||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Observers</th><th scope="col">Avg SNR</th><th scope="col">Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byObservers.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
@@ -1497,7 +1506,7 @@
|
||||
|
||||
<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>
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Last Heard</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byRecent.slice(0, 15).map(n => `<tr>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
@@ -1529,7 +1538,7 @@
|
||||
|
||||
// Category stats
|
||||
const cats = data.catStats;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">Max (km)</th></tr></thead><tbody>`;
|
||||
for (const [cat, st] of Object.entries(cats)) {
|
||||
if (!st.count) continue;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
|
||||
@@ -1549,7 +1558,7 @@
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
@@ -1563,7 +1572,7 @@
|
||||
|
||||
// Top paths
|
||||
if (data.topPaths.length) {
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
data.topPaths.slice(0, 10).forEach((p, i) => {
|
||||
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
|
||||
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
|
||||
|
||||
+1
-1
@@ -263,7 +263,7 @@
|
||||
<div class="alab-section">
|
||||
<h3>🎹 Note Sequence</h3>
|
||||
<table class="alab-note-table">
|
||||
<tr><th></th><th>#</th><th>Payload Index</th><th>Byte</th><th>→ MIDI</th><th>→ Freq</th><th>Duration (why)</th><th>Gap (why)</th></tr>
|
||||
<tr><th scope="col"></th><th scope="col">#</th><th scope="col">Payload Index</th><th scope="col">Byte</th><th scope="col">→ MIDI</th><th scope="col">→ Freq</th><th scope="col">Duration (why)</th><th scope="col">Gap (why)</th></tr>
|
||||
${m.notes.map((n, i) => {
|
||||
const durWhy = `byte ${n.byte} → map(0...255 → 50...400ms) × tempo`;
|
||||
const gapWhy = i < m.notes.length - 1
|
||||
|
||||
+13
-4
@@ -57,6 +57,15 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
'<div id="compareContent"></div>' +
|
||||
'</div>';
|
||||
|
||||
// #209 — Keyboard accessibility for compare table rows
|
||||
app.addEventListener('keydown', function (e) {
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (!row) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
location.hash = row.dataset.value;
|
||||
});
|
||||
|
||||
loadObservers();
|
||||
}
|
||||
|
||||
@@ -316,9 +325,9 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
|
||||
el.innerHTML =
|
||||
(hashes.length > displayLimit ? '<div class="text-muted" style="margin-bottom:8px">Showing first ' + displayLimit + ' of ' + hashes.length.toLocaleString() + ' packets.</div>' : '') +
|
||||
'<table class="data-table compare-table">' +
|
||||
'<div class="analytics-table-scroll"><table class="data-table compare-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Hash</th><th>Time</th><th>Type</th><th>Observer</th>' +
|
||||
'<th scope="col">Hash</th><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Observer</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + displayed.map(function (h) {
|
||||
var p = mapA.get(h) || mapB.get(h);
|
||||
@@ -332,7 +341,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
} else {
|
||||
obsLabel = nameB;
|
||||
}
|
||||
return '<tr style="cursor:pointer" onclick="location.hash=\'#/packets/' + escapeHtml(h) + '\'">' +
|
||||
return '<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/' + escapeHtml(h) + '" onclick="location.hash=\'#/packets/' + escapeHtml(h) + '\'">' +
|
||||
'<td class="mono" style="font-size:0.85em">' + escapeHtml(h.substring(0, 12)) + '</td>' +
|
||||
'<td>' + timeAgo(p.timestamp || p.first_seen) + '</td>' +
|
||||
'<td><span class="payload-badge badge-' + payloadTypeColor(p.payload_type) + '">' + escapeHtml(typeName) + '</span></td>' +
|
||||
@@ -340,7 +349,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>';
|
||||
'</table></div>';
|
||||
}
|
||||
|
||||
registerPage('compare', { init: init, destroy: destroy });
|
||||
|
||||
+24
-24
@@ -642,21 +642,21 @@
|
||||
var b = state.branding;
|
||||
var logoPreview = b.logoUrl ? '<img class="cust-preview-img" src="' + escAttr(b.logoUrl) + '" alt="Logo preview" onerror="this.style.display=\'none\'">' : '';
|
||||
return '<div class="cust-panel' + (activeTab === 'branding' ? ' active' : '') + '" data-panel="branding">' +
|
||||
'<div class="cust-field"><label>Site Name</label><input type="text" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
|
||||
'<div class="cust-field"><label>Tagline</label><input type="text" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
|
||||
'<div class="cust-field"><label>Logo URL</label><input type="text" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
|
||||
'<div class="cust-field"><label>Favicon URL</label><input type="text" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
|
||||
'<div class="cust-field"><label for="cust-siteName">Site Name</label><input type="text" id="cust-siteName" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-tagline">Tagline</label><input type="text" id="cust-tagline" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-logoUrl">Logo URL</label><input type="text" id="cust-logoUrl" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
|
||||
'<div class="cust-field"><label for="cust-faviconUrl">Favicon URL</label><input type="text" id="cust-faviconUrl" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderColorRow(key, val, def, dataAttr) {
|
||||
var isFont = key === 'font' || key === 'mono';
|
||||
var inputHtml = isFont
|
||||
? '<input type="text" data-' + dataAttr + '="' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">'
|
||||
: '<input type="color" data-' + dataAttr + '="' + key + '" value="' + val + '">' +
|
||||
? '<input type="text" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">'
|
||||
: '<input type="color" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + val + '">' +
|
||||
'<span class="cust-hex" data-hex="' + key + '">' + val + '</span>';
|
||||
return '<div class="cust-color-row">' +
|
||||
'<div><label>' + THEME_LABELS[key] + '</label>' +
|
||||
'<div><label for="cust-' + dataAttr + '-' + key + '">' + THEME_LABELS[key] + '</label>' +
|
||||
'<div class="cust-hint">' + (THEME_HINTS[key] || '') + '</div></div>' +
|
||||
inputHtml +
|
||||
(val !== def ? '<button class="cust-reset-btn" data-reset-theme="' + key + '">Reset</button>' : '') +
|
||||
@@ -708,9 +708,9 @@
|
||||
var val = state.nodeColors[key];
|
||||
var def = DEFAULTS.nodeColors[key];
|
||||
rows += '<div class="cust-color-row">' +
|
||||
'<div><label>' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + '</label>' +
|
||||
'<div><label for="cust-node-' + key + '">' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + '</label>' +
|
||||
'<div class="cust-hint">' + (NODE_HINTS[key] || '') + '</div></div>' +
|
||||
'<input type="color" data-node="' + key + '" value="' + val + '">' +
|
||||
'<input type="color" id="cust-node-' + key + '" data-node="' + key + '" value="' + val + '">' +
|
||||
'<span class="cust-node-dot" style="background:' + val + '" data-dot="' + key + '"></span>' +
|
||||
'<span class="cust-hex" data-nhex="' + key + '">' + val + '</span>' +
|
||||
(val !== def ? '<button class="cust-reset-btn" data-reset-node="' + key + '">Reset</button>' : '') +
|
||||
@@ -721,9 +721,9 @@
|
||||
var tval = state.typeColors[tkey];
|
||||
var tdef = DEFAULTS.typeColors[tkey];
|
||||
typeRows += '<div class="cust-color-row">' +
|
||||
'<div><label>' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + '</label>' +
|
||||
'<div><label for="cust-type-' + tkey + '">' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + '</label>' +
|
||||
'<div class="cust-hint">' + (TYPE_HINTS[tkey] || '') + '</div></div>' +
|
||||
'<input type="color" data-type-color="' + tkey + '" value="' + tval + '">' +
|
||||
'<input type="color" id="cust-type-' + tkey + '" data-type-color="' + tkey + '" value="' + tval + '">' +
|
||||
'<span class="cust-node-dot" style="background:' + tval + '" data-tdot="' + tkey + '"></span>' +
|
||||
'<span class="cust-hex" data-thex="' + tkey + '">' + tval + '</span>' +
|
||||
(tval !== tdef ? '<button class="cust-reset-btn" data-reset-type="' + tkey + '">Reset</button>' : '') +
|
||||
@@ -742,13 +742,13 @@
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
'<p class="cust-section-title">Heatmap Opacity</p>' +
|
||||
'<div class="cust-color-row">' +
|
||||
'<div><label>🗺️ Nodes Map</label>' +
|
||||
'<div><label for="custHeatOpacity">🗺️ Nodes Map</label>' +
|
||||
'<div class="cust-hint">Heatmap overlay on the Nodes → Map page (0–100%)</div></div>' +
|
||||
'<input type="range" id="custHeatOpacity" min="0" max="100" value="' + heatPct + '" style="width:120px;cursor:pointer">' +
|
||||
'<span id="custHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + heatPct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="cust-color-row">' +
|
||||
'<div><label>📡 Live Map</label>' +
|
||||
'<div><label for="custLiveHeatOpacity">📡 Live Map</label>' +
|
||||
'<div class="cust-hint">Heatmap overlay on the Live page (0–100%)</div></div>' +
|
||||
'<input type="range" id="custLiveHeatOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
|
||||
'<span id="custLiveHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + liveHeatPct + '%</span>' +
|
||||
@@ -761,13 +761,13 @@
|
||||
var stepsHtml = h.steps.map(function (s, i) {
|
||||
return '<div class="cust-list-item" data-step="' + i + '">' +
|
||||
'<div class="cust-list-row">' +
|
||||
'<input class="cust-emoji-input" data-step-field="emoji" data-idx="' + i + '" value="' + escAttr(s.emoji) + '" placeholder="📡">' +
|
||||
'<input data-step-field="title" data-idx="' + i + '" value="' + escAttr(s.title) + '" placeholder="Title">' +
|
||||
'<input class="cust-emoji-input" data-step-field="emoji" data-idx="' + i + '" value="' + escAttr(s.emoji) + '" placeholder="📡" aria-label="Step ' + (i + 1) + ' emoji">' +
|
||||
'<input data-step-field="title" data-idx="' + i + '" value="' + escAttr(s.title) + '" placeholder="Title" aria-label="Step ' + (i + 1) + ' title">' +
|
||||
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="up" title="Move up">↑</button>' +
|
||||
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="down" title="Move down">↓</button>' +
|
||||
'<button class="cust-list-btn danger" data-rm-step="' + i + '" title="Remove">✕</button>' +
|
||||
'</div>' +
|
||||
'<textarea data-step-field="description" data-idx="' + i + '" placeholder="Description" rows="2">' + esc(s.description) + '</textarea>' +
|
||||
'<textarea data-step-field="description" data-idx="' + i + '" placeholder="Description" rows="2" aria-label="Step ' + (i + 1) + ' description">' + esc(s.description) + '</textarea>' +
|
||||
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -775,10 +775,10 @@
|
||||
var checkHtml = h.checklist.map(function (c, i) {
|
||||
return '<div class="cust-list-item" data-check="' + i + '">' +
|
||||
'<div class="cust-list-row">' +
|
||||
'<input data-check-field="question" data-idx="' + i + '" value="' + escAttr(c.question) + '" placeholder="Question">' +
|
||||
'<input data-check-field="question" data-idx="' + i + '" value="' + escAttr(c.question) + '" placeholder="Question" aria-label="Checklist item ' + (i + 1) + ' question">' +
|
||||
'<button class="cust-list-btn danger" data-rm-check="' + i + '" title="Remove">✕</button>' +
|
||||
'</div>' +
|
||||
'<textarea data-check-field="answer" data-idx="' + i + '" placeholder="Answer" rows="2">' + esc(c.answer) + '</textarea>' +
|
||||
'<textarea data-check-field="answer" data-idx="' + i + '" placeholder="Answer" rows="2" aria-label="Checklist item ' + (i + 1) + ' answer">' + esc(c.answer) + '</textarea>' +
|
||||
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -786,16 +786,16 @@
|
||||
var linksHtml = h.footerLinks.map(function (l, i) {
|
||||
return '<div class="cust-list-item" data-link="' + i + '">' +
|
||||
'<div class="cust-list-row">' +
|
||||
'<input data-link-field="label" data-idx="' + i + '" value="' + escAttr(l.label) + '" placeholder="Label">' +
|
||||
'<input data-link-field="label" data-idx="' + i + '" value="' + escAttr(l.label) + '" placeholder="Label" aria-label="Footer link ' + (i + 1) + ' label">' +
|
||||
'<button class="cust-list-btn danger" data-rm-link="' + i + '" title="Remove">✕</button>' +
|
||||
'</div>' +
|
||||
'<input data-link-field="url" data-idx="' + i + '" value="' + escAttr(l.url) + '" placeholder="URL">' +
|
||||
'<input data-link-field="url" data-idx="' + i + '" value="' + escAttr(l.url) + '" placeholder="URL" aria-label="Footer link ' + (i + 1) + ' URL">' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div class="cust-panel' + (activeTab === 'home' ? ' active' : '') + '" data-panel="home">' +
|
||||
'<div class="cust-field"><label>Hero Title</label><input type="text" data-key="home.heroTitle" value="' + escAttr(h.heroTitle) + '"></div>' +
|
||||
'<div class="cust-field"><label>Hero Subtitle</label><input type="text" data-key="home.heroSubtitle" value="' + escAttr(h.heroSubtitle) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-heroTitle">Hero Title</label><input type="text" id="cust-heroTitle" data-key="home.heroTitle" value="' + escAttr(h.heroTitle) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-heroSubtitle">Hero Subtitle</label><input type="text" id="cust-heroSubtitle" data-key="home.heroSubtitle" value="' + escAttr(h.heroSubtitle) + '"></div>' +
|
||||
'<p class="cust-section-title" style="margin-top:20px">Steps</p>' + stepsHtml +
|
||||
'<button class="cust-add-btn" id="addStep">+ Add Step</button>' +
|
||||
'<p class="cust-section-title" style="margin-top:24px">FAQ / Checklist</p>' + checkHtml +
|
||||
@@ -870,11 +870,11 @@
|
||||
'<div class="cust-export-btns" style="margin-bottom:12px">' +
|
||||
'<button class="cust-dl-btn" id="custDownload">💾 Download theme.json</button>' +
|
||||
'<button class="cust-dl-btn" id="custImportFile">📂 Import File</button>' +
|
||||
'<input type="file" id="custImportInput" accept=".json,application/json" style="display:none">' +
|
||||
'<input type="file" id="custImportInput" accept=".json,application/json" style="display:none" aria-label="Import theme file">' +
|
||||
'<button class="cust-copy-btn" id="custCopy">📋 Copy</button>' +
|
||||
'</div>' +
|
||||
'<details style="margin-top:8px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted)">Raw JSON</summary>' +
|
||||
'<textarea class="cust-export-area" id="custExportJson" style="margin-top:8px">' + esc(json) + '</textarea>' +
|
||||
'<textarea class="cust-export-area" id="custExportJson" style="margin-top:8px" aria-label="Theme JSON data">' + esc(json) + '</textarea>' +
|
||||
'</details>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
+27
-27
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774690620">
|
||||
<link rel="stylesheet" href="home.css?v=1774690620">
|
||||
<link rel="stylesheet" href="live.css?v=1774690620">
|
||||
<link rel="stylesheet" href="style.css?v=1774690803">
|
||||
<link rel="stylesheet" href="home.css?v=1774690803">
|
||||
<link rel="stylesheet" href="live.css?v=1774690803">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,29 +81,29 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774690620"></script>
|
||||
<script src="customize.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774690620"></script>
|
||||
<script src="hop-resolver.js?v=1774690620"></script>
|
||||
<script src="hop-display.js?v=1774690620"></script>
|
||||
<script src="app.js?v=1774690620"></script>
|
||||
<script src="home.js?v=1774690620"></script>
|
||||
<script src="packet-filter.js?v=1774690620"></script>
|
||||
<script src="packets.js?v=1774690620"></script>
|
||||
<script src="map.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774690620" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774690803"></script>
|
||||
<script src="customize.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774690803"></script>
|
||||
<script src="hop-resolver.js?v=1774690803"></script>
|
||||
<script src="hop-display.js?v=1774690803"></script>
|
||||
<script src="app.js?v=1774690803"></script>
|
||||
<script src="home.js?v=1774690803"></script>
|
||||
<script src="packet-filter.js?v=1774690803"></script>
|
||||
<script src="packets.js?v=1774690803"></script>
|
||||
<script src="map.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774690803" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -102,27 +102,27 @@
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Activity Timeline</h4>
|
||||
<div class="analytics-chart-desc">Packet count per time bucket — shows when this node is most active</div>
|
||||
<canvas id="activityChart"></canvas>
|
||||
<canvas id="activityChart" role="img" aria-label="Activity timeline chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>SNR Trend</h4>
|
||||
<div class="analytics-chart-desc">Signal-to-noise ratio over time — higher is better reception</div>
|
||||
<canvas id="snrChart"></canvas>
|
||||
<canvas id="snrChart" role="img" aria-label="SNR trend chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Packet Types</h4>
|
||||
<div class="analytics-chart-desc">Breakdown of advert, position, text, and other packet types</div>
|
||||
<canvas id="packetTypeChart"></canvas>
|
||||
<canvas id="packetTypeChart" role="img" aria-label="Packet types chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Observer Coverage</h4>
|
||||
<div class="analytics-chart-desc">Which stations hear this node and how often</div>
|
||||
<canvas id="observerChart"></canvas>
|
||||
<canvas id="observerChart" role="img" aria-label="Observer coverage chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Hop Distribution</h4>
|
||||
<div class="analytics-chart-desc">How many repeater hops packets take — 0 means direct</div>
|
||||
<canvas id="hopChart"></canvas>
|
||||
<canvas id="hopChart" role="img" aria-label="Hop distribution chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Uptime Heatmap</h4>
|
||||
@@ -132,14 +132,14 @@
|
||||
${data.peerInteractions.length ? `<div class="analytics-chart-card full">
|
||||
<h4>Peer Interactions</h4>
|
||||
<div class="analytics-chart-desc">Nodes this device has exchanged messages with</div>
|
||||
<table class="analytics-peer-table">
|
||||
<thead><tr><th>Peer</th><th>Messages</th><th>Last Contact</th></tr></thead>
|
||||
<div class="analytics-table-scroll"><table class="analytics-peer-table">
|
||||
<thead><tr><th scope="col">Peer</th><th scope="col">Messages</th><th scope="col">Last Contact</th></tr></thead>
|
||||
<tbody>${data.peerInteractions.map(p => `<tr>
|
||||
<td><a href="#/nodes/${encodeURIComponent(p.peer_key)}" style="color:var(--accent)">${escapeHtml(p.peer_name)}</a></td>
|
||||
<td>${p.messageCount}</td>
|
||||
<td>${timeAgo(p.lastContact)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
+6
-6
@@ -287,7 +287,7 @@
|
||||
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<table class="data-table" style="font-size:12px">
|
||||
<thead><tr><th>Observer</th><th>Region</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
|
||||
<thead><tr><th scope="col">Observer</th><th scope="col">Region</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Avg RSSI</th></tr></thead>
|
||||
<tbody>
|
||||
${observers.map(o => `<tr>
|
||||
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
|
||||
@@ -577,11 +577,11 @@
|
||||
</div>
|
||||
<table class="data-table" id="nodesTable">
|
||||
<thead><tr>
|
||||
<th class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
|
||||
<th class="col-pubkey sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
|
||||
<th class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
|
||||
<th class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
|
||||
<th class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
|
||||
<th scope="col" class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
|
||||
<th scope="col" class="col-pubkey sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
|
||||
<th scope="col" class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
|
||||
<th scope="col" class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
|
||||
<th scope="col" class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
|
||||
</tr></thead>
|
||||
<tbody id="nodesBody"></tbody>
|
||||
</table>`;
|
||||
|
||||
@@ -157,19 +157,19 @@
|
||||
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
|
||||
<canvas id="obsTimeChart"></canvas>
|
||||
<canvas id="obsTimeChart" role="img" aria-label="Packets over time chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packet Types</h3>
|
||||
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart"></canvas></div>
|
||||
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart" role="img" aria-label="Packet types chart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Unique Nodes Heard</h3>
|
||||
<canvas id="obsNodesChart"></canvas>
|
||||
<canvas id="obsNodesChart" role="img" aria-label="Unique nodes heard chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">SNR Distribution</h3>
|
||||
<canvas id="obsSnrChart"></canvas>
|
||||
<canvas id="obsSnrChart" role="img" aria-label="SNR distribution chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
@@ -299,12 +299,12 @@
|
||||
const el = document.getElementById('obsRecentPackets');
|
||||
if (!el || !packets.length) { if (el) el.innerHTML = '<div class="text-muted">No recent packets.</div>'; return; }
|
||||
el.innerHTML = `<table class="data-table" style="font-size:0.85em">
|
||||
<thead><tr><th>Time</th><th>Type</th><th>Hash</th><th>SNR</th><th>RSSI</th><th>Hops</th></tr></thead>
|
||||
<thead><tr><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Hash</th><th scope="col">SNR</th><th scope="col">RSSI</th><th scope="col">Hops</th></tr></thead>
|
||||
<tbody>${packets.map(p => {
|
||||
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
|
||||
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
|
||||
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/packets/${p.hash || p.id}'">
|
||||
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/${p.hash || p.id}" onclick="location.hash='#/packets/${p.hash || p.id}'">
|
||||
<td>${timeAgo(p.timestamp)}</td>
|
||||
<td>${typeName}</td>
|
||||
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>
|
||||
@@ -314,6 +314,15 @@
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
|
||||
// #209 — Keyboard accessibility for recent packet rows
|
||||
el.addEventListener('keydown', function (e) {
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (!row) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
location.hash = row.dataset.value;
|
||||
});
|
||||
}
|
||||
|
||||
registerPage('observer-detail', { init, destroy });
|
||||
|
||||
+13
-3
@@ -25,6 +25,16 @@
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (row) location.hash = row.dataset.value;
|
||||
});
|
||||
// #209 — Keyboard accessibility for observer rows
|
||||
app.addEventListener('keydown', function (e) {
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (!row) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
location.hash = row.dataset.value;
|
||||
});
|
||||
// Auto-refresh every 30s
|
||||
refreshTimer = setInterval(loadObservers, 30000);
|
||||
@@ -113,13 +123,13 @@
|
||||
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
<th>Status</th><th>Name</th><th>Region</th><th>Last Seen</th>
|
||||
<th>Packets</th><th>Packets/Hour</th><th>Uptime</th>
|
||||
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Seen</th>
|
||||
<th scope="col">Packets</th><th scope="col">Packets/Hour</th><th scope="col">Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>${filtered.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
||||
<td class="mono">${o.name || o.id}</td>
|
||||
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
||||
|
||||
+5
-4
@@ -501,6 +501,7 @@
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px">
|
||||
<input type="text" id="packetFilterInput" class="packet-filter-input"
|
||||
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
|
||||
aria-label="Packet filter expression"
|
||||
style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:13px;background:var(--input-bg);color:var(--text)">
|
||||
<div id="packetFilterError" style="color:var(--status-red);font-size:11px;margin-top:2px;display:none"></div>
|
||||
<div id="packetFilterCount" style="color:var(--text-muted);font-size:11px;margin-top:2px;display:none"></div>
|
||||
@@ -528,7 +529,7 @@
|
||||
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes">★ My Nodes</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="fTimeWindow" class="filter-select">
|
||||
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
|
||||
<option value="15">Last 15 min</option>
|
||||
<option value="30">Last 30 min</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
@@ -559,8 +560,8 @@
|
||||
</div>
|
||||
<table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th></th><th class="col-region">Region</th><th class="col-time">Time</th><th class="col-hash">Hash</th><th class="col-size">Size</th>
|
||||
<th class="col-type">Type</th><th class="col-observer">Observer</th><th class="col-path">Path</th><th class="col-rpt">Rpt</th><th class="col-details">Details</th>
|
||||
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
|
||||
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table>
|
||||
@@ -1524,7 +1525,7 @@
|
||||
}
|
||||
|
||||
return `<table class="field-table">
|
||||
<thead><tr><th>Offset</th><th>Field</th><th>Value</th><th>Description</th></tr></thead>
|
||||
<thead><tr><th scope="col">Offset</th><th scope="col">Field</th><th scope="col">Value</th><th scope="col">Description</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user