diff --git a/app/routes/api.py b/app/routes/api.py index aa13581..f23dfec 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -5889,6 +5889,72 @@ def repeater_telemetry(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/repeaters//neighbours', methods=['GET']) +def repeater_neighbours(public_key): + """Zero-hop neighbours of a repeater, enriched with contact names/coords. + + Neighbour entries carry only a 4-byte pubkey prefix; names and GPS + positions are resolved by prefix-matching device contacts (with the + DB contact cache as fallback), so unknown repeaters stay prefix-only. + """ + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device not connected'}), 503 + pk = _normalize_repeater_key(public_key) + if not pk: + return jsonify({'success': False, 'error': 'Invalid public_key'}), 400 + login_error = _require_repeater_login(dm, pk) + if login_error: + return login_error + try: + result = dm.repeater_req_neighbours(pk) + if not result.get('success'): + return jsonify({'success': False, + 'error': result.get('error', 'No neighbours response')}), _repeater_result_status(result) + + data = result['data'] or {} + db = _get_db() + entries = [] + for n in data.get('neighbours', []): + prefix = (n.get('pubkey') or '').lower() + entry = { + 'pubkey_prefix': prefix, + 'secs_ago': n.get('secs_ago'), + 'snr': n.get('snr'), + 'name': '', + 'lat': None, + 'lon': None, + } + contact = dm.mc.get_contact_by_key_prefix(prefix) if dm.mc else None + if contact: + entry['name'] = contact.get('adv_name', '') or '' + lat = contact.get('adv_lat') + lon = contact.get('adv_lon') + if lat and lon and (lat != 0 or lon != 0): + entry['lat'] = lat + entry['lon'] = lon + elif db: + db_contact = db.get_contact_by_prefix(prefix) + if db_contact: + entry['name'] = db_contact.get('name', '') or '' + lat = db_contact.get('adv_lat') + lon = db_contact.get('adv_lon') + if lat and lon and (lat != 0 or lon != 0): + entry['lat'] = lat + entry['lon'] = lon + entries.append(entry) + + return jsonify({ + 'success': True, + 'total': data.get('neighbours_count', len(entries)), + 'fetched': data.get('results_count', len(entries)), + 'entries': entries, + }), 200 + except Exception as e: + logger.error(f"Error getting repeater neighbours: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/repeaters//clock', methods=['GET']) def repeater_clock(public_key): """Current clock of a repeater (unix seconds + formatted).""" diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index c20af04..46c642f 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -237,6 +237,10 @@ function openToolPane(tool) { renderTelemetryPane(body); return; } + if (tool.key === 'neighbors') { + renderNeighborsPane(body); + return; + } body.innerHTML = `
@@ -569,6 +573,244 @@ function renderTelemetryCards(container, lpp) { container.innerHTML = html; } +// ================================================================ +// Neighbors tool +// ================================================================ + +let _neighborsData = null; // last successful response +let _neighborsView = 'list'; // 'list' | 'map' +let _nbMap = null; +let _nbMapLayers = null; + +function renderNeighborsPane(body) { + _neighborsView = 'list'; + _nbMap = null; // pane body was rebuilt; force map re-init + body.innerHTML = ` +
+ + + +
+
+ + `; + body.querySelector('#neighborsRefreshBtn').addEventListener('click', loadNeighbors); + body.querySelector('#nbListBtn').addEventListener('click', () => setNeighborsView('list')); + body.querySelector('#nbMapBtn').addEventListener('click', () => setNeighborsView('map')); + loadNeighbors(); +} + +async function loadNeighbors() { + const container = document.getElementById('neighborsContainer'); + const btn = document.getElementById('neighborsRefreshBtn'); + if (!container) return; + if (btn) btn.disabled = true; + setNeighborsView('list'); + container.innerHTML = ` +
+
+

Requesting neighbours from the repeater…
+ Long lists are fetched in pages and can take a while.

+
+ `; + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/neighbours`); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + if (btn) btn.disabled = false; + + if (!data || !data.success) { + const countEl = document.getElementById('neighborsCount'); + if (countEl) countEl.textContent = ''; + container.innerHTML = ` +
+ +

${esc((data && data.error) || 'Failed to get neighbours')}

+ +
+ `; + const retry = document.getElementById('neighborsRetryBtn'); + if (retry) retry.addEventListener('click', loadNeighbors); + return; + } + + _neighborsData = data; + renderNeighborsList(); +} + +function neighborLabel(n) { + return n.name || `[${n.pubkey_prefix}]`; +} + +function renderNeighborsList() { + const container = document.getElementById('neighborsContainer'); + const countEl = document.getElementById('neighborsCount'); + const toggle = document.getElementById('neighborsViewToggle'); + const data = _neighborsData; + if (!container || !data) return; + + const entries = data.entries || []; + if (countEl) { + let label = `${data.total} neighbor${data.total === 1 ? '' : 's'}`; + if (data.fetched < data.total) label += ` (showing ${data.fetched})`; + countEl.textContent = label; + } + + // Map view is offered when the repeater or any neighbour has a position + const mappable = entries.some(n => n.lat != null && n.lon != null) + || (_repeater && _repeater.adv_lat && _repeater.adv_lon); + if (toggle) toggle.style.display = mappable ? '' : 'none'; + + if (!entries.length) { + container.innerHTML = '
No neighbours reported yet.
Neighbours are learned from zero-hop repeater adverts.
'; + return; + } + + const rows = entries.map(n => ` + + + ${n.name ? esc(n.name) : `[${esc(n.pubkey_prefix)}]`} + ${n.lat != null ? '' : ''} + + ${fmtHeardAgo(n.secs_ago)} + ${n.snr != null ? n.snr + ' dB' : '—'} + + `).join(''); + container.innerHTML = ` + + + + + + + ${rows} +
RepeaterHeardSNR
+ `; +} + +function fmtHeardAgo(secs) { + if (secs == null) return '—'; + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; + return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h ago`; +} + +function setNeighborsView(view) { + _neighborsView = view; + const listWrap = document.getElementById('neighborsContainer'); + const mapWrap = document.getElementById('neighborsMapWrap'); + const listBtn = document.getElementById('nbListBtn'); + const mapBtn = document.getElementById('nbMapBtn'); + if (!listWrap || !mapWrap) return; + const isMap = view === 'map'; + listWrap.style.display = isMap ? 'none' : ''; + mapWrap.style.display = isMap ? '' : 'none'; + if (listBtn) listBtn.classList.toggle('active', !isMap); + if (mapBtn) mapBtn.classList.toggle('active', isMap); + if (isMap) renderNeighborsMap(); +} + +function renderNeighborsMap() { + const data = _neighborsData; + if (!data) return; + + if (!_nbMap) { + _nbMap = L.map('nbLeafletMap').setView([52.0, 19.0], 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap' + }).addTo(_nbMap); + _nbMapLayers = L.layerGroup().addTo(_nbMap); + } + _nbMapLayers.clearLayers(); + + const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon; + const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null; + const bounds = []; + + if (hasCenter) { + const centerMarker = L.circleMarker(center, { + radius: 11, + fillColor: '#dc3545', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.9 + }).addTo(_nbMapLayers); + centerMarker.bindPopup(`${esc(_repeater.name || 'This repeater')}
managed repeater`); + bounds.push(center); + } + + let placed = 0; + let skipped = 0; + (data.entries || []).forEach(n => { + if (n.lat == null || n.lon == null) { skipped++; return; } + const pos = [n.lat, n.lon]; + const marker = L.circleMarker(pos, { + radius: 9, + fillColor: '#198754', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.85 + }).addTo(_nbMapLayers); + marker.bindPopup( + `${esc(neighborLabel(n))}
` + + `SNR: ${n.snr != null ? n.snr + ' dB' : '—'}
` + + `Heard: ${fmtHeardAgo(n.secs_ago)}` + ); + if (hasCenter) { + const line = L.polyline([center, pos], { + color: '#6c757d', + weight: 2, + dashArray: '6 6', + opacity: 0.8 + }).addTo(_nbMapLayers); + line.bindTooltip(`${n.snr != null ? n.snr + ' dB' : '?'}`, { + permanent: true, + direction: 'center', + className: 'nb-snr-tooltip' + }); + } + bounds.push(pos); + placed++; + }); + + const note = document.getElementById('nbMapNote'); + if (note) { + const parts = [`${placed} neighbor${placed === 1 ? '' : 's'} with known position`]; + if (skipped) parts.push(`${skipped} without position not shown`); + if (!hasCenter) parts.push('managed repeater has no position — connection lines unavailable'); + note.textContent = parts.join(' · '); + } + + // Leaflet needs a size recalc after the container becomes visible + setTimeout(() => { + _nbMap.invalidateSize(); + if (bounds.length > 0) { + _nbMap.fitBounds(bounds, { padding: [30, 30] }); + } + }, 50); +} + async function loadClock() { const el = document.getElementById('statusClock'); if (!el) return; diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index 4327d15..9f4ba1c 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -24,6 +24,10 @@ + + @@ -167,6 +171,22 @@ padding: 1rem; } + /* SNR labels on neighbor map connection lines */ + .nb-snr-tooltip { + background: rgba(13, 110, 253, 0.92); + color: #fff; + border: none; + border-radius: 0.5rem; + padding: 1px 6px; + font-size: 0.72rem; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .nb-snr-tooltip::before { + display: none; + } + /* Centered state screens */ .state-screen { text-align: center; @@ -301,6 +321,10 @@ + +