mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 01:13:11 +02:00
Merge pull request #17 from ipnet-mesh/claude/implement-map-page-01F5nbj6KfXDv4Dsp1KE6jJZ
Updates
This commit is contained in:
@@ -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:-}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user