Merge pull request #17 from ipnet-mesh/claude/implement-map-page-01F5nbj6KfXDv4Dsp1KE6jJZ

Updates
This commit is contained in:
JingleManSweep
2025-12-04 19:35:15 +00:00
committed by GitHub
11 changed files with 78 additions and 88 deletions
-1
View File
@@ -237,7 +237,6 @@ services:
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
- NETWORK_CITY=${NETWORK_CITY:-}
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
- NETWORK_LOCATION=${NETWORK_LOCATION:-}
- NETWORK_RADIO_CONFIG=${NETWORK_RADIO_CONFIG:-}
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
+1 -1
View File
@@ -20,7 +20,7 @@ router = APIRouter()
async def list_members(
_: RequireRead,
session: DbSession,
limit: int = Query(default=50, ge=1, le=100),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> MemberList:
"""List all members with pagination."""
+1 -1
View File
@@ -19,7 +19,7 @@ async def list_nodes(
session: DbSession,
search: Optional[str] = Query(None, description="Search in name or public key"),
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
limit: int = Query(50, ge=1, le=100, description="Page size"),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
"""List all nodes with pagination and filtering."""
-3
View File
@@ -231,9 +231,6 @@ class WebSettings(CommonSettings):
network_country: Optional[str] = Field(
default=None, description="Network country (ISO 3166-1 alpha-2)"
)
network_location: Optional[str] = Field(
default=None, description="Network location (lat,lon)"
)
network_radio_config: Optional[str] = Field(
default=None, description="Radio configuration details"
)
-4
View File
@@ -53,7 +53,6 @@ def create_app(
network_name: str | None = None,
network_city: str | None = None,
network_country: str | None = None,
network_location: tuple[float, float] | None = None,
network_radio_config: str | None = None,
network_contact_email: str | None = None,
network_contact_discord: str | None = None,
@@ -71,7 +70,6 @@ def create_app(
network_name: Display name for the network
network_city: City where the network is located
network_country: Country where the network is located
network_location: (lat, lon) tuple for map centering
network_radio_config: Radio configuration description
network_contact_email: Contact email address
network_contact_discord: Discord invite/server info
@@ -101,7 +99,6 @@ def create_app(
app.state.network_name = network_name or settings.network_name
app.state.network_city = network_city or settings.network_city
app.state.network_country = network_country or settings.network_country
app.state.network_location = network_location or (0.0, 0.0)
app.state.network_radio_config = (
network_radio_config or settings.network_radio_config
)
@@ -168,7 +165,6 @@ def get_network_context(request: Request) -> dict:
"network_name": request.app.state.network_name,
"network_city": request.app.state.network_city,
"network_country": request.app.state.network_country,
"network_location": request.app.state.network_location,
"network_radio_config": radio_config,
"network_contact_email": request.app.state.network_contact_email,
"network_contact_discord": request.app.state.network_contact_discord,
-29
View File
@@ -60,20 +60,6 @@ import click
envvar="NETWORK_COUNTRY",
help="Network country",
)
@click.option(
"--network-lat",
type=float,
default=None,
envvar="NETWORK_LAT",
help="Network center latitude",
)
@click.option(
"--network-lon",
type=float,
default=None,
envvar="NETWORK_LON",
help="Network center longitude",
)
@click.option(
"--network-radio-config",
type=str,
@@ -126,8 +112,6 @@ def web(
network_name: str | None,
network_city: str | None,
network_country: str | None,
network_lat: float | None,
network_lon: float | None,
network_radio_config: str | None,
network_contact_email: str | None,
network_contact_discord: str | None,
@@ -190,21 +174,9 @@ def web(
effective_country = network_country or settings.network_country
if effective_city and effective_country:
click.echo(f"Location: {effective_city}, {effective_country}")
effective_lat = network_lat if network_lat is not None else 0.0
effective_lon = network_lon if network_lon is not None else 0.0
if effective_lat != 0.0 or effective_lon != 0.0:
click.echo(f"Map center: {effective_lat}, {effective_lon}")
click.echo(f"Reload mode: {reload}")
click.echo("=" * 50)
# Build network_location tuple only if explicitly provided
network_location: tuple[float, float] | None = None
if network_lat is not None or network_lon is not None:
network_location = (
network_lat if network_lat is not None else 0.0,
network_lon if network_lon is not None else 0.0,
)
if reload:
# For development, use uvicorn's reload feature
click.echo("\nStarting in development mode with auto-reload...")
@@ -225,7 +197,6 @@ def web(
network_name=network_name,
network_city=network_city,
network_country=network_country,
network_location=network_location,
network_radio_config=network_radio_config,
network_contact_email=network_contact_email,
network_contact_discord=network_contact_discord,
+13 -5
View File
@@ -124,20 +124,28 @@ async def map_data(request: Request) -> JSONResponse:
error = str(e)
logger.warning(f"Failed to fetch nodes for map: {e}")
# Get network center location
network_location = request.app.state.network_location
logger.info(
f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates"
)
# Calculate center from nodes, or use default (0, 0)
center_lat = 0.0
center_lon = 0.0
if nodes_with_location:
center_lat = sum(n["lat"] for n in nodes_with_location) / len(
nodes_with_location
)
center_lon = sum(n["lon"] for n in nodes_with_location) / len(
nodes_with_location
)
return JSONResponse(
{
"nodes": nodes_with_location,
"members": members_list,
"center": {
"lat": network_location[0],
"lon": network_location[1],
"lat": center_lat,
"lon": center_lon,
},
"debug": {
"total_nodes": total_nodes,
+47 -26
View File
@@ -73,24 +73,24 @@
<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" style="background-color: oklch(var(--p));"></div>
<span class="text-lg">💬</span>
<span>Chat</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full" style="background-color: oklch(var(--s));"></div>
<span class="text-lg">📡</span>
<span>Repeater</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full" style="background-color: oklch(var(--a));"></div>
<span class="text-lg">🪧</span>
<span>Room</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full" style="background-color: oklch(var(--n));"></div>
<span class="text-lg">📍</span>
<span>Other</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full" style="background-color: oklch(var(--n)); border: 2px solid gold; box-shadow: 0 0 4px gold;"></div>
<span>Infrastructure</span>
<span class="text-lg" style="filter: drop-shadow(0 0 4px gold);">📡</span>
<span>Infrastructure (gold glow)</span>
</div>
</div>
@@ -101,8 +101,8 @@
{% block extra_scripts %}
<script>
// Initialize map
const map = L.map('map').setView([{{ network_location[0] }}, {{ network_location[1] }}], 10);
// Initialize map with world view (will be centered on nodes once loaded)
const map = L.map('map').setView([0, 0], 2);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -113,27 +113,44 @@
let allNodes = [];
let allMembers = [];
let markers = [];
let mapCenter = { lat: {{ network_location[0] }}, lon: {{ network_location[1] }} };
let mapCenter = { lat: 0, lon: 0 };
// Get marker color based on node type
function getMarkerColor(node) {
if (node.adv_type === 'chat') return 'oklch(var(--p))';
if (node.adv_type === 'repeater') return 'oklch(var(--s))';
if (node.adv_type === 'room') return 'oklch(var(--a))';
return 'oklch(var(--n))';
// Normalize adv_type to lowercase for consistent comparison
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
// Get emoji marker based on node type
function getNodeEmoji(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return '💬';
if (type === 'repeater') return '📡';
if (type === 'room') return '🪧';
return '📍';
}
// Get display name for node type
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
// Create marker icon for a node
function createNodeIcon(node) {
const color = getMarkerColor(node);
const infraStyle = node.is_infra
? 'border: 3px solid gold; box-shadow: 0 0 8px gold, 0 2px 4px rgba(0,0,0,0.3);'
: 'border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);';
const emoji = getNodeEmoji(node);
const infraGlow = node.is_infra ? 'filter: drop-shadow(0 0 4px gold);' : '';
const keyPrefix = node.public_key ? node.public_key.substring(0, 2).toLowerCase() : '';
return L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; ${infraStyle}"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6]
html: `<div style="display: flex; align-items: center; gap: 2px;">
<span style="font-size: 24px; ${infraGlow} text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${keyPrefix}</span>
</div>`,
iconSize: [52, 28],
iconAnchor: [14, 14]
});
}
@@ -153,11 +170,14 @@
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
}
const emoji = getNodeEmoji(node);
const typeDisplay = getTypeDisplay(node);
return `
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${node.name}</h3>
<h3 class="font-bold text-lg mb-2">${emoji} ${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">Type:</span> ${typeDisplay}</p>
${roleHtml}
${ownerHtml}
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
@@ -183,8 +203,8 @@
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Type filter
if (typeFilter && node.adv_type !== typeFilter) return false;
// Type filter (case-insensitive)
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
// Infrastructure filter
if (infraOnly && !node.is_infra) return false;
@@ -295,6 +315,7 @@
// Log debug info
const debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
+8 -8
View File
@@ -106,16 +106,16 @@
{% endif %}
</td>
<td>
{% if ad.adv_type == 'chat' %}
<span class="badge badge-primary badge-xs">chat</span>
{% elif ad.adv_type == 'repeater' %}
<span class="badge badge-secondary badge-xs">repeater</span>
{% elif ad.adv_type == 'room' %}
<span class="badge badge-accent badge-xs">room</span>
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif ad.adv_type %}
<span class="badge badge-ghost badge-xs">{{ ad.adv_type }}</span>
<span title="{{ ad.adv_type }}">📍</span>
{% else %}
<span class="badge badge-ghost badge-xs">-</span>
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
+8 -8
View File
@@ -69,16 +69,16 @@
<a href="/nodes/{{ node.public_key }}" class="link link-hover">{{ ns.friendly_name or node.name or '-' }}</a>
</td>
<td>
{% if node.adv_type == 'chat' %}
<span class="badge badge-primary badge-sm">chat</span>
{% elif node.adv_type == 'repeater' %}
<span class="badge badge-secondary badge-sm">repeater</span>
{% elif node.adv_type == 'room' %}
<span class="badge badge-accent badge-sm">room</span>
{% if node.adv_type and node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type and node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type and node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif node.adv_type %}
<span class="badge badge-ghost badge-sm">{{ node.adv_type }}</span>
<span title="{{ node.adv_type }}">📍</span>
{% else %}
<span class="badge badge-ghost badge-sm">-</span>
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="font-mono text-xs truncate-cell" title="{{ node.public_key }}">
-2
View File
@@ -224,7 +224,6 @@ def web_app(mock_http_client: MockHttpClient) -> Any:
network_name="Test Network",
network_city="Test City",
network_country="Test Country",
network_location=(40.7128, -74.0060),
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
@@ -298,7 +297,6 @@ def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
network_name="Test Network",
network_city="Test City",
network_country="Test Country",
network_location=(40.7128, -74.0060),
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",