mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-29 13:03:18 +02:00
feat(repeaters): Neighbors tool with map view (stage 5)
List view: all zero-hop neighbours (fetch_all_neighbours paginates the firmware's ~14-entry pages), rows show resolved contact name (or [pubkey prefix] for unknown repeaters), heard-ago and SNR; count in the toolbar. Names/positions are enriched server-side by prefix- matching device contacts with the DB contact cache as fallback. Map view (List/Map toggle, shown only when something is mappable): Leaflet with the managed repeater as a red marker, positioned neighbours in green, dashed connection lines labeled with permanent SNR tooltips, and a footnote counting neighbours without a known position. Toggle hidden entirely when no coordinates exist. Verified live on PL-KRA Wegrzce 2: 37/37 neighbours fetched, 20 mappable, SNR labels rendered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5889,6 +5889,72 @@ def repeater_telemetry(public_key):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/repeaters/<public_key>/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/<public_key>/clock', methods=['GET'])
|
||||
def repeater_clock(public_key):
|
||||
"""Current clock of a repeater (unix seconds + formatted)."""
|
||||
|
||||
@@ -237,6 +237,10 @@ function openToolPane(tool) {
|
||||
renderTelemetryPane(body);
|
||||
return;
|
||||
}
|
||||
if (tool.key === 'neighbors') {
|
||||
renderNeighborsPane(body);
|
||||
return;
|
||||
}
|
||||
body.innerHTML = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
|
||||
@@ -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 = `
|
||||
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="text-muted small flex-grow-1" id="neighborsCount"></span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="neighborsViewToggle" style="display: none;">
|
||||
<button type="button" class="btn btn-outline-secondary active" id="nbListBtn">
|
||||
<i class="bi bi-list-ul"></i> List
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="nbMapBtn">
|
||||
<i class="bi bi-map"></i> Map
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="neighborsRefreshBtn" title="Refresh neighbours">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="neighborsContainer"></div>
|
||||
<div id="neighborsMapWrap" style="display: none;">
|
||||
<div id="nbLeafletMap" style="height: 420px; width: 100%;" class="rounded border"></div>
|
||||
<div class="small text-muted mt-1" id="nbMapNote"></div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="mt-2 mb-0">Requesting neighbours from the repeater…<br>
|
||||
<span class="small">Long lists are fetched in pages and can take a while.</span></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get neighbours')}</p>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="neighborsRetryBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Try again
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = '<div class="text-muted text-center py-4">No neighbours reported yet.<br><small>Neighbours are learned from zero-hop repeater adverts.</small></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = entries.map(n => `
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width: 220px;">
|
||||
${n.name ? esc(n.name) : `<span class="font-monospace text-muted">[${esc(n.pubkey_prefix)}]</span>`}
|
||||
${n.lat != null ? '<i class="bi bi-geo-alt text-muted small ms-1" title="Position known"></i>' : ''}
|
||||
</td>
|
||||
<td class="text-muted text-nowrap">${fmtHeardAgo(n.secs_ago)}</td>
|
||||
<td class="text-end text-nowrap fw-medium">${n.snr != null ? n.snr + ' dB' : '—'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
container.innerHTML = `
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead><tr class="small text-muted">
|
||||
<th class="fw-normal">Repeater</th>
|
||||
<th class="fw-normal">Heard</th>
|
||||
<th class="fw-normal text-end">SNR</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
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: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).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(`<b>${esc(_repeater.name || 'This repeater')}</b><br>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(
|
||||
`<b>${esc(neighborLabel(n))}</b><br>` +
|
||||
`SNR: ${n.snr != null ? n.snr + ' dB' : '—'}<br>` +
|
||||
`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;
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
||||
<!-- Leaflet CSS (neighbors map view) -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Theme CSS (light/dark mode) -->
|
||||
@@ -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 @@
|
||||
|
||||
<!-- Bootstrap JS Bundle (local) -->
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- Leaflet JS (neighbors map view) -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
<!-- Repeater Management JS -->
|
||||
<script src="{{ url_for('static', filename='js/repeater-manage.js') }}"></script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user