mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Add filters to map page for node type, infrastructure, and owner
- Enhanced /map/data endpoint to include node role tag and member ownership - Added client-side filtering for node type (chat, repeater, room) - Added toggle to filter for infrastructure nodes only (role: infra) - Added dropdown filter for member owner (nodes linked via public_key) - Color-coded markers by node type with gold border for infrastructure - Added legend showing marker types - Dynamic count display showing total vs filtered nodes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Map page route."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@@ -23,10 +24,31 @@ async def map_page(request: Request) -> HTMLResponse:
|
||||
|
||||
@router.get("/map/data")
|
||||
async def map_data(request: Request) -> JSONResponse:
|
||||
"""Return node location data as JSON for the map."""
|
||||
nodes_with_location = []
|
||||
"""Return node location data as JSON for the map.
|
||||
|
||||
Includes role tag, member ownership info, and all data needed for filtering.
|
||||
"""
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_key: dict[str, dict[str, Any]] = {}
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by public_key
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
member_info = {
|
||||
"id": member.get("id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("public_key"):
|
||||
members_by_key[member["public_key"]] = member_info
|
||||
|
||||
# Fetch all nodes from API
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
@@ -41,6 +63,8 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
lat = None
|
||||
lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
if key == "lat":
|
||||
@@ -55,6 +79,8 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
pass
|
||||
elif key == "friendly_name":
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
# Use friendly_name, then node name, then public key prefix
|
||||
@@ -63,14 +89,22 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
or node.get("name")
|
||||
or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
|
||||
# Find owner member if exists
|
||||
owner = members_by_key.get(public_key)
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
"public_key": node.get("public_key"),
|
||||
"public_key": public_key,
|
||||
"name": display_name,
|
||||
"adv_type": node.get("adv_type"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -83,6 +117,7 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"nodes": nodes_with_location,
|
||||
"members": members_list,
|
||||
"center": {
|
||||
"lat": network_location[0],
|
||||
"lon": network_location[1],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#map {
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 350px);
|
||||
min-height: 400px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
@@ -16,13 +16,67 @@
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
.marker-chat {
|
||||
background-color: oklch(var(--p));
|
||||
}
|
||||
.marker-repeater {
|
||||
background-color: oklch(var(--s));
|
||||
}
|
||||
.marker-room {
|
||||
background-color: oklch(var(--a));
|
||||
}
|
||||
.marker-default {
|
||||
background-color: oklch(var(--n));
|
||||
}
|
||||
.marker-infra {
|
||||
border: 3px solid gold !important;
|
||||
box-shadow: 0 0 8px gold, 0 2px 4px rgba(0,0,0,0.3) !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Node Map</h1>
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node Type</span>
|
||||
</label>
|
||||
<select id="filter-type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="repeater">Repeater</option>
|
||||
<option value="room">Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Owner</span>
|
||||
</label>
|
||||
<select id="filter-owner" class="select select-bordered select-sm">
|
||||
<option value="">All Owners</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Infrastructure Only</span>
|
||||
<input type="checkbox" id="filter-infra" class="checkbox checkbox-sm checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@@ -31,9 +85,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
|
||||
<p>To add a node to the map, set its location tags via the API.</p>
|
||||
<!-- Legend -->
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 rounded-full marker-chat"></div>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 rounded-full marker-repeater"></div>
|
||||
<span>Repeater</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 rounded-full marker-room"></div>
|
||||
<span>Room</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 rounded-full marker-default"></div>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 rounded-full marker-default marker-infra"></div>
|
||||
<span>Infrastructure</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags. Infrastructure nodes are tagged with <code>role: infra</code>.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -47,53 +125,182 @@
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Custom marker icon
|
||||
const nodeIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="background-color: oklch(var(--p)); width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
// Store all nodes and markers
|
||||
let allNodes = [];
|
||||
let allMembers = [];
|
||||
let markers = [];
|
||||
let mapCenter = { lat: {{ network_location[0] }}, lon: {{ network_location[1] }} };
|
||||
|
||||
// Get marker color class based on node type
|
||||
function getMarkerClass(node) {
|
||||
let baseClass = 'marker-default';
|
||||
if (node.adv_type === 'chat') baseClass = 'marker-chat';
|
||||
else if (node.adv_type === 'repeater') baseClass = 'marker-repeater';
|
||||
else if (node.adv_type === 'room') baseClass = 'marker-room';
|
||||
|
||||
if (node.is_infra) baseClass += ' marker-infra';
|
||||
return baseClass;
|
||||
}
|
||||
|
||||
// Create marker icon for a node
|
||||
function createNodeIcon(node) {
|
||||
const markerClass = getMarkerClass(node);
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div class="${markerClass}" style="width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
}
|
||||
|
||||
// Create popup content for a node
|
||||
function createPopupContent(node) {
|
||||
let ownerHtml = '';
|
||||
if (node.owner) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? `${node.owner.name} (${node.owner.callsign})`
|
||||
: node.owner.name;
|
||||
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${node.adv_type || 'Unknown'}</p>
|
||||
${roleHtml}
|
||||
${ownerHtml}
|
||||
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
|
||||
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
|
||||
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
|
||||
</div>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Clear all markers from map
|
||||
function clearMarkers() {
|
||||
markers.forEach(marker => map.removeLayer(marker));
|
||||
markers = [];
|
||||
}
|
||||
|
||||
// Apply filters and update map
|
||||
function applyFilters() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const ownerFilter = document.getElementById('filter-owner').value;
|
||||
const infraOnly = document.getElementById('filter-infra').checked;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter
|
||||
if (typeFilter && node.adv_type !== typeFilter) return false;
|
||||
|
||||
// Infrastructure filter
|
||||
if (infraOnly && !node.is_infra) return false;
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter) {
|
||||
if (!node.owner || node.owner.id !== ownerFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Clear existing markers
|
||||
clearMarkers();
|
||||
|
||||
// Add filtered markers
|
||||
filteredNodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
|
||||
marker.bindPopup(createPopupContent(node));
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
// Update counts
|
||||
const countEl = document.getElementById('node-count');
|
||||
const filteredEl = document.getElementById('filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = `${allNodes.length} nodes on map`;
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = `${allNodes.length} total`;
|
||||
filteredEl.textContent = `${filteredNodes.length} shown`;
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Fit bounds if we have filtered nodes
|
||||
if (filteredNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate owner filter dropdown
|
||||
function populateOwnerFilter() {
|
||||
const select = document.getElementById('filter-owner');
|
||||
|
||||
// Get unique owners from nodes that have locations
|
||||
const ownersWithNodes = new Set();
|
||||
allNodes.forEach(node => {
|
||||
if (node.owner) {
|
||||
ownersWithNodes.add(node.owner.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter members to only those who own nodes on the map
|
||||
const relevantMembers = allMembers.filter(m => ownersWithNodes.has(m.id));
|
||||
|
||||
// Sort by name
|
||||
relevantMembers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options
|
||||
relevantMembers.forEach(member => {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.id;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-owner').value = '';
|
||||
document.getElementById('filter-infra').checked = false;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-owner').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-infra').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
fetch('/map/data')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const nodes = data.nodes;
|
||||
const center = data.center;
|
||||
allNodes = data.nodes;
|
||||
allMembers = data.members || [];
|
||||
mapCenter = data.center;
|
||||
|
||||
// Update node count
|
||||
document.getElementById('node-count').textContent = `${nodes.length} nodes on map`;
|
||||
// Populate owner filter
|
||||
populateOwnerFilter();
|
||||
|
||||
// Add markers for each node
|
||||
nodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: nodeIcon }).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${node.adv_type || 'Unknown'}</p>
|
||||
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
|
||||
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
|
||||
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
|
||||
</div>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
});
|
||||
|
||||
// Fit bounds if we have nodes
|
||||
if (nodes.length > 0) {
|
||||
const bounds = L.latLngBounds(nodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} else if (center.lat !== 0 || center.lon !== 0) {
|
||||
// Use network center if no nodes
|
||||
map.setView([center.lat, center.lon], 10);
|
||||
}
|
||||
// Initial display
|
||||
applyFilters();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading map data:', error);
|
||||
|
||||
Reference in New Issue
Block a user