Files
pyMC_Repeater/repeater/templates/neighbors.html
T

741 lines
26 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - Neighbors</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Neighbor Repeaters</h1>
<div class="header-info">
<span>Tracking: <strong id="neighbor-count">0</strong> repeaters</span>
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
</div>
</header>
<!-- Neighbors Map -->
<div class="map-card">
<h2>Repeater Network Map</h2>
<div id="map-container" class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Neighbors Table -->
<div class="table-card">
<h2>Discovered Repeaters</h2>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Node Name</th>
<th>Public Key</th>
<th>Contact Type</th>
<th>Location</th>
<th>RSSI</th>
<th>SNR</th>
<th>Last Seen</th>
<th>First Seen</th>
<th>Advert Count</th>
</tr>
</thead>
<tbody id="neighbors-table">
<tr>
<td colspan="9" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<script>
let updateInterval;
let map = null;
let centerMarker = null;
let neighborMarkers = [];
let connectionLines = [];
let configLat = null;
let configLng = null;
// Initialize map
function initMap() {
if (map || !configLat || !configLng) return; // Already initialized or no coords yet
map = L.map('map').setView([configLat, configLng], 10);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
tileSize: 256,
className: 'map-tiles'
}).addTo(map);
// Add center marker (your repeater)
centerMarker = L.circleMarker([configLat, configLng], {
radius: 8,
fillColor: '#6a9955',
color: '#4ade80',
weight: 3,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
centerMarker.bindPopup('<strong>Your Repeater</strong><br>(' + configLat.toFixed(4) + ', ' + configLng.toFixed(4) + ')');
// Force map to recalculate size after container is visible
setTimeout(() => {
map.invalidateSize();
}, 100);
}
function updateMapData(stats) {
// Get lat/lng from API
if (stats.config && stats.config.repeater) {
configLat = stats.config.repeater.latitude;
configLng = stats.config.repeater.longitude;
}
// Initialize map if needed (after we have coords from API)
if (!map) {
initMap();
}
if (!map) return; // Still no valid coords
// Update center marker position
if (centerMarker) {
centerMarker.setLatLng([configLat, configLng]);
}
// Clear existing neighbor markers and lines
neighborMarkers.forEach(m => map.removeLayer(m));
connectionLines.forEach(l => map.removeLayer(l));
neighborMarkers = [];
connectionLines = [];
// Add neighbor markers and connection lines
const neighbors = stats.neighbors || {};
const neighborsArray = Object.entries(neighbors);
if (neighborsArray.length === 0) return;
neighborsArray.forEach(([pubkey, neighbor]) => {
if (!neighbor.latitude || !neighbor.longitude) return;
if (neighbor.latitude === 0.0 && neighbor.longitude === 0.0) return;
const lat = neighbor.latitude;
const lng = neighbor.longitude;
const name = neighbor.node_name || 'Unknown';
// Add neighbor marker
const marker = L.circleMarker([lat, lng], {
radius: 6,
fillColor: '#4ec9b0',
color: '#ce9178',
weight: 2,
opacity: 1,
fillOpacity: 0.7
}).addTo(map);
const popupText = `<strong>${name}</strong><br>
RSSI: ${neighbor.rssi || 'N/A'}<br>
SNR: ${neighbor.snr ? neighbor.snr.toFixed(1) + ' dB' : 'N/A'}<br>
Adverts: ${neighbor.advert_count || 0}<br>
(${lat.toFixed(4)}, ${lng.toFixed(4)})`;
marker.bindPopup(popupText);
neighborMarkers.push(marker);
// Draw connection line from center to neighbor
const line = L.polyline([
[configLat, configLng],
[lat, lng]
], {
color: '#4ec9b0',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(map);
connectionLines.push(line);
});
// Fit map to show all markers
if (neighborsArray.length > 0) {
const group = new L.featureGroup([centerMarker, ...neighborMarkers]);
map.fitBounds(group.getBounds(), { padding: [50, 50] });
}
}
// Handle Send Advert button
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
if (!btn) return;
const icon = btn.querySelector('.icon');
const iconHTML = icon ? icon.outerHTML : '';
btn.disabled = true;
btn.innerHTML = iconHTML + 'Sending...';
fetch('/api/send_advert', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.innerHTML = iconHTML + 'Sent!';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
} else {
btn.innerHTML = iconHTML + 'Error';
console.error('Failed to send advert:', data.error);
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
}
})
.catch(e => {
console.error('Error sending advert:', e);
btn.innerHTML = iconHTML + 'Error';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
});
}
function updateNeighbors() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
const neighbors = data.neighbors || {};
const neighborCount = Object.keys(neighbors).length;
document.getElementById('neighbor-count').textContent = neighborCount;
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
updateNeighborsTable(neighbors);
updateMapData(data);
})
.catch(e => console.error('Error fetching neighbors:', e));
}
// Mock test data function
function updateNeighborsTable(neighbors) {
// Mock data for testing
const mockNeighbors = {
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1": {
node_name: "Test Repeater 1",
contact_type: "Repeater",
latitude: 50.7185,
longitude: -1.5339,
rssi: -75,
snr: 8.5,
last_seen: Math.floor(Date.now() / 1000) - 30,
first_seen: Math.floor(Date.now() / 1000) - 3600,
advert_count: 5
},
"def456abc123def456abc123def456abc123def456abc123def456abc123def4": {
node_name: "Test Repeater 2",
contact_type: "Repeater",
latitude: 50.5185,
longitude: -1.9339,
rssi: -85,
snr: 5.2,
last_seen: Math.floor(Date.now() / 1000) - 60,
first_seen: Math.floor(Date.now() / 1000) - 7200,
advert_count: 12
},
"ghi789jkl012ghi789jkl012ghi789jkl012ghi789jkl012ghi789jkl012ghi7": {
node_name: "Test Repeater 3",
contact_type: "Repeater",
latitude: 50.6185,
longitude: -1.6339,
rssi: -65,
snr: 12.3,
last_seen: Math.floor(Date.now() / 1000) - 5,
first_seen: Math.floor(Date.now() / 1000) - 1800,
advert_count: 3
}
};
// Use mock data instead of real neighbors
neighbors = mockNeighbors;
const tbody = document.getElementById('neighbors-table');
if (!neighbors || Object.keys(neighbors).length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
`;
return;
}
// Sort by last_seen (most recent first)
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
return b[1].last_seen - a[1].last_seen;
});
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
const name = neighbor.node_name || 'Unknown';
// Format pubkey properly - it's a 64-char hex string
const pubkeyShort = pubkey.length >= 16
? `&lt;${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}&gt;`
: `&lt;${pubkey}&gt;`;
const contactType = neighbor.contact_type || 'Repeater';
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
: 'N/A';
const rssi = neighbor.rssi || 'N/A';
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
const advertCount = neighbor.advert_count || 0;
// Color code RSSI
let rssiClass = 'rssi-poor';
if (rssi !== 'N/A') {
if (rssi > -80) rssiClass = 'rssi-excellent';
else if (rssi > -90) rssiClass = 'rssi-good';
else if (rssi > -100) rssiClass = 'rssi-fair';
}
return `
<tr>
<td data-label="Node Name"><strong>${name}</strong></td>
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
<td data-label="Location">${location}</td>
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
<td data-label="SNR">${snr}</td>
<td data-label="Last Seen">${lastSeen}</td>
<td data-label="First Seen">${firstSeen}</td>
<td data-label="Advert Count">${advertCount}</td>
</tr>
`;
}).join('');
}
/* ORIGINAL FUNCTION - COMMENTED OUT
function updateNeighborsTable(neighbors) {
const tbody = document.getElementById('neighbors-table');
if (!neighbors || Object.keys(neighbors).length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-message">
No repeaters discovered yet - waiting for adverts...
</td>
</tr>
`;
return;
}
// Sort by last_seen (most recent first)
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
return b[1].last_seen - a[1].last_seen;
});
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
const name = neighbor.node_name || 'Unknown';
// Format pubkey properly - it's a 64-char hex string
const pubkeyShort = pubkey.length >= 16
? `&lt;${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}&gt;`
: `&lt;${pubkey}&gt;`;
const contactType = neighbor.contact_type || 'Repeater';
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
: 'N/A';
const rssi = neighbor.rssi || 'N/A';
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
const advertCount = neighbor.advert_count || 0;
// Color code RSSI
let rssiClass = 'rssi-poor';
if (rssi !== 'N/A') {
if (rssi > -80) rssiClass = 'rssi-excellent';
else if (rssi > -90) rssiClass = 'rssi-good';
else if (rssi > -100) rssiClass = 'rssi-fair';
}
return `
<tr>
<td data-label="Node Name"><strong>${name}</strong></td>
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
<td data-label="Location">${location}</td>
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
<td data-label="SNR">${snr}</td>
<td data-label="Last Seen">${lastSeen}</td>
<td data-label="First Seen">${firstSeen}</td>
<td data-label="Advert Count">${advertCount}</td>
</tr>
`;
}).join('');
}
*/
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
updateNeighbors();
// Auto-update every 10 seconds
updateInterval = setInterval(updateNeighbors, 10000);
// Attach send advert button handler
const sendAdvertBtn = document.getElementById('send-advert-btn');
if (sendAdvertBtn) {
sendAdvertBtn.addEventListener('click', sendAdvert);
}
});
</script>
<style>
.map-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.map-card h2 {
margin-top: 0;
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
font-size: 1.5rem;
}
.map-container {
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
}
#map {
height: 400px;
width: 100%;
z-index: 1;
}
/* Dark theme for leaflet */
.map-tiles {
filter: invert(0.93) hue-rotate(200deg) saturate(0.5);
}
.leaflet-control-attribution {
background: rgba(30, 30, 30, 0.7) !important;
color: #999 !important;
font-size: 0.75rem;
}
.leaflet-control-attribution a {
color: #4ec9b0 !important;
}
.leaflet-control {
background: var(--color-bg-secondary) !important;
border: 1px solid var(--color-border) !important;
color: var(--color-text-primary) !important;
}
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
color: var(--color-text-primary) !important;
background: var(--color-bg-secondary) !important;
}
.leaflet-control-zoom-in:hover,
.leaflet-control-zoom-out:hover {
background: var(--color-bg-tertiary) !important;
}
.leaflet-popup-content-wrapper {
background: var(--color-bg-secondary) !important;
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
}
.leaflet-popup-content {
color: var(--color-text-primary) !important;
margin: 0 !important;
font-size: 0.85rem;
}
.leaflet-popup-content strong {
color: #4ec9b0 !important;
}
.leaflet-popup-tip {
background: var(--color-bg-secondary) !important;
border: 1px solid var(--color-border) !important;
}
@media (max-width: 768px) {
#map {
height: 300px;
}
.map-card {
padding: var(--spacing-md);
}
.map-card h2 {
font-size: 1.25rem;
}
}
@media (max-width: 480px) {
#map {
height: 250px;
}
.map-card {
padding: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.map-card h2 {
font-size: 1.1rem;
margin-bottom: var(--spacing-sm);
}
.leaflet-popup-content {
font-size: 0.75rem;
}
}
.pubkey {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
}
.contact-type-badge {
display: inline-block;
padding: 4px 8px;
background-color: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 4px;
font-size: 0.85em;
color: #60a5fa;
font-weight: 500;
}
.rssi-excellent {
color: #4ade80;
font-weight: 500;
}
.rssi-good {
color: #4ec9b0;
font-weight: 500;
}
.rssi-fair {
color: #dcdcaa;
font-weight: 500;
}
.rssi-poor {
color: #f48771;
font-weight: 500;
}
/* Mobile responsive table styling */
@media (max-width: 768px) {
.data-table {
display: block;
width: 100%;
}
.data-table thead {
display: none;
}
.data-table tbody {
display: block;
width: 100%;
}
.data-table tbody tr {
display: block;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
overflow: hidden;
}
.data-table tbody tr:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
margin-right: var(--spacing-lg);
padding: 0;
border: none;
text-align: left;
font-size: 0.75rem;
flex-wrap: wrap;
}
.data-table td::before {
content: attr(data-label);
font-weight: var(--font-weight-bold);
color: var(--color-text-tertiary);
text-transform: uppercase;
font-size: 0.6rem;
letter-spacing: 0.5px;
min-width: fit-content;
padding: 2px 6px;
background: var(--color-bg-tertiary);
border-radius: 4px;
}
/* Node Name and Public Key get full width */
.data-table td:nth-child(1),
.data-table td:nth-child(2) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-md);
}
.data-table td:nth-child(1)::before { content: "Node Name"; }
.data-table td:nth-child(2)::before { content: "Public Key"; }
.data-table td:nth-child(3)::before { content: "Contact Type"; }
.data-table td:nth-child(4)::before { content: "Location"; }
.data-table td:nth-child(5)::before { content: "RSSI"; }
.data-table td:nth-child(6)::before { content: "SNR"; }
.data-table td:nth-child(7)::before { content: "Last Seen"; }
.data-table td:nth-child(8)::before { content: "First Seen"; }
.data-table td:nth-child(9)::before { content: "Advert Count"; }
/* Location and timestamps wrap to next line */
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
}
@media (max-width: 600px) {
.data-table tbody tr {
padding: var(--spacing-md);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
margin-right: var(--spacing-md);
font-size: 0.7rem;
}
.data-table td::before {
font-size: 0.55rem;
padding: 1px 4px;
}
/* Full width items */
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
.pubkey {
font-size: 0.7rem;
word-break: break-all;
}
}
@media (max-width: 480px) {
.data-table tbody tr {
padding: var(--spacing-sm);
}
.data-table td {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
margin-right: var(--spacing-sm);
font-size: 0.65rem;
}
.data-table td::before {
font-size: 0.5rem;
padding: 1px 3px;
}
/* Full width items */
.data-table td:nth-child(1),
.data-table td:nth-child(2),
.data-table td:nth-child(4),
.data-table td:nth-child(7),
.data-table td:nth-child(8) {
display: block;
width: 100%;
margin-right: 0;
margin-bottom: var(--spacing-xs);
}
.pubkey {
font-size: 0.6rem;
word-break: break-all;
white-space: normal;
}
.contact-type-badge {
font-size: 0.7rem;
padding: 2px 4px;
}
}
</style>
</body>
</html>