Compare commits

...

4 Commits

Author SHA1 Message Date
Louis King
92b0b883e6 More website improvements 2025-12-08 17:07:39 +00:00
Louis King
9e621c0029 Fixed test 2025-12-08 16:42:13 +00:00
Louis King
a251f3a09f Added map to node detail page, made title consistent with emoji 2025-12-08 16:37:53 +00:00
Louis King
0fdedfe5ba Tidied Advert/Node search 2025-12-08 16:22:08 +00:00
10 changed files with 275 additions and 111 deletions

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
@@ -89,6 +89,9 @@ def _fetch_receivers_for_events(
async def list_advertisements(
_: RequireRead,
session: DbSession,
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
public_key: Optional[str] = Query(None, description="Filter by public key"),
received_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
@@ -118,6 +121,22 @@ async def list_advertisements(
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
)
if search:
# Search in public key, advertisement name, node name, or name tag
search_pattern = f"%{search}%"
query = query.where(
or_(
Advertisement.public_key.ilike(search_pattern),
Advertisement.name.ilike(search_pattern),
SourceNode.name.ilike(search_pattern),
SourceNode.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
)
),
)
)
if public_key:
query = query.where(Advertisement.public_key == public_key)

View File

@@ -31,6 +31,7 @@ async def get_stats(
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = now - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)
# Total nodes
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
@@ -73,6 +74,26 @@ async def get_stats(
or 0
)
# Advertisements in last 7 days
advertisements_7d = (
session.execute(
select(func.count())
.select_from(Advertisement)
.where(Advertisement.received_at >= seven_days_ago)
).scalar()
or 0
)
# Messages in last 7 days
messages_7d = (
session.execute(
select(func.count())
.select_from(Message)
.where(Message.received_at >= seven_days_ago)
).scalar()
or 0
)
# Recent advertisements (last 10)
recent_ads = (
session.execute(
@@ -185,8 +206,10 @@ async def get_stats(
active_nodes=active_nodes,
total_messages=total_messages,
messages_today=messages_today,
messages_7d=messages_7d,
total_advertisements=total_advertisements,
advertisements_24h=advertisements_24h,
advertisements_7d=advertisements_7d,
recent_advertisements=recent_advertisements,
channel_message_counts=channel_message_counts,
channel_messages=channel_messages,

View File

@@ -239,10 +239,14 @@ class DashboardStats(BaseModel):
active_nodes: int = Field(..., description="Nodes active in last 24h")
total_messages: int = Field(..., description="Total number of messages")
messages_today: int = Field(..., description="Messages received today")
messages_7d: int = Field(default=0, description="Messages received in last 7 days")
total_advertisements: int = Field(..., description="Total advertisements")
advertisements_24h: int = Field(
default=0, description="Advertisements received in last 24h"
)
advertisements_7d: int = Field(
default=0, description="Advertisements received in last 7 days"
)
recent_advertisements: list[RecentAdvertisement] = Field(
default_factory=list, description="Last 10 advertisements"
)

View File

@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/advertisements", response_class=HTMLResponse)
async def advertisements_list(
request: Request,
public_key: str | None = Query(None, description="Filter by public key"),
search: str | None = Query(None, description="Search term"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
@@ -28,8 +28,8 @@ async def advertisements_list(
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if public_key:
params["public_key"] = public_key
if search:
params["search"] = search
# Fetch advertisements from API
advertisements = []
@@ -57,7 +57,7 @@ async def advertisements_list(
"page": page,
"limit": limit,
"total_pages": total_pages,
"public_key": public_key or "",
"search": search or "",
}
)

View File

@@ -23,11 +23,11 @@
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Public Key</span>
<span class="label-text">Search</span>
</label>
<input type="text" name="public_key" value="{{ public_key }}" placeholder="Filter by public key..." class="input input-bordered input-sm w-80" />
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<button type="submit" class="btn btn-primary btn-sm">Search</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
@@ -126,7 +126,7 @@
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
<a href="?page={{ page - 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
@@ -135,14 +135,14 @@
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
<a href="?page={{ p }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
<a href="?page={{ page + 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}

View File

@@ -19,25 +19,25 @@
</p>
{% endif %}
<div class="flex gap-4 justify-center flex-wrap">
<a href="/network" class="btn btn-primary">
<a href="/network" class="btn btn-neutral">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Dashboard
</a>
<a href="/nodes" class="btn btn-secondary">
<a href="/nodes" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Nodes
</a>
<a href="/advertisements" class="btn btn-accent">
<a href="/advertisements" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Advertisements
</a>
<a href="/messages" class="btn btn-info">
<a href="/messages" class="btn btn-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
@@ -49,7 +49,7 @@
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
@@ -62,7 +62,7 @@
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Advertisements (24h) -->
<!-- Advertisements (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -70,32 +70,20 @@
</svg>
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_24h }}</div>
<div class="stat-desc">Received in last 24 hours</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Total Messages -->
<!-- Messages (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title">Total Messages</div>
<div class="stat-value text-accent">{{ stats.total_messages }}</div>
<div class="stat-desc">All time</div>
</div>
<!-- Messages Today -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title">Messages Today</div>
<div class="stat-value text-info">{{ stats.messages_today }}</div>
<div class="stat-desc">Last 24 hours</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
@@ -239,8 +227,8 @@
datasets: [{
label: 'Advertisements',
data: counts,
borderColor: 'oklch(0.7 0.15 250)',
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,

View File

@@ -22,8 +22,63 @@
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Advertisements (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Messages (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
<!-- Activity Charts -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Node Count Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
<!-- Advertisements Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -55,22 +110,6 @@
</div>
</div>
</div>
<!-- Node Count Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Additional Stats -->
@@ -163,27 +202,6 @@
{% endif %}
</div>
<!-- Quick Actions -->
<div class="flex gap-4 mt-8 flex-wrap">
<a href="/nodes" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Browse Nodes
</a>
<a href="/messages" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
View Messages
</a>
<a href="/map" class="btn btn-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
View Map
</a>
</div>
{% endblock %}
{% block extra_scripts %}
@@ -232,7 +250,7 @@
});
}
// Advertisements chart
// Advertisements chart (secondary color - pink/magenta)
const advertCtx = document.getElementById('advertChart');
if (advertCtx && advertData.data && advertData.data.length > 0) {
new Chart(advertCtx, {
@@ -242,8 +260,8 @@
datasets: [{
label: 'Advertisements',
data: advertData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 250)',
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
@@ -254,7 +272,7 @@
});
}
// Messages chart
// Messages chart (accent color - teal/cyan)
const messageCtx = document.getElementById('messageChart');
if (messageCtx && messageData.data && messageData.data.length > 0) {
new Chart(messageCtx, {
@@ -264,8 +282,8 @@
datasets: [{
label: 'Messages',
data: messageData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 160)',
backgroundColor: 'oklch(0.7 0.15 160 / 0.1)',
borderColor: 'oklch(0.75 0.18 180)',
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
@@ -276,7 +294,7 @@
});
}
// Node count chart
// Node count chart (primary color - purple/blue)
const nodeCtx = document.getElementById('nodeChart');
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
new Chart(nodeCtx, {
@@ -286,8 +304,8 @@
datasets: [{
label: 'Total Nodes',
data: nodeData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 30)',
backgroundColor: 'oklch(0.7 0.15 30 / 0.1)',
borderColor: 'oklch(0.65 0.24 265)',
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,

View File

@@ -2,6 +2,22 @@
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block extra_head %}
<style>
#node-map {
height: 300px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block content %}
<div class="breadcrumbs text-sm mb-4">
<ul>
@@ -41,10 +57,18 @@
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{{ ns.tag_name or node.name or 'Unnamed Node' }}
{% if node.adv_type %}
<span class="badge badge-secondary">{{ node.adv_type }}</span>
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
{% endif %}
{% endif %}
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
@@ -61,32 +85,55 @@
</div>
</div>
<!-- Tags -->
{% if node.tags %}
<div class="mt-6">
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Tags and Map Grid -->
{% set ns_map = namespace(lat=none, lon=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% endif %}
{% endfor %}
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Location Map -->
{% if ns_map.lat and ns_map.lon %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
<div id="node-map" class="mb-2"></div>
<div class="text-sm opacity-70">
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -208,3 +255,67 @@
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if node %}
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% elif tag.key == 'name' %}
{% set ns_map.name = tag.value %}
{% endif %}
{% endfor %}
{% if ns_map.lat and ns_map.lon %}
<script>
// Initialize map centered on the node's location
const nodeLat = {{ ns_map.lat }};
const nodeLon = {{ ns_map.lon }};
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
const nodeType = {{ (node.adv_type or '') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Get emoji marker based on node type
function getNodeEmoji(type) {
const normalizedType = type ? type.toLowerCase() : null;
if (normalizedType === 'chat') return '💬';
if (normalizedType === 'repeater') return '📡';
if (normalizedType === 'room') return '🪧';
return '📍';
}
// Create marker icon (just the emoji, no label)
const emoji = getNodeEmoji(nodeType);
const icon = L.divIcon({
className: 'custom-div-icon',
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
// Add marker
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Add popup (shown on click, not by default)
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
<div class="space-y-1 text-sm">
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
</div>
</div>
`);
</script>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -25,7 +25,7 @@
<label class="label py-1">
<span class="label-text">Search</span>
</label>
<input type="text" name="search" value="{{ search }}" placeholder="Name tag, node name, or public key..." class="input input-bordered input-sm w-64" />
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<div class="form-control">
<label class="label py-1">

View File

@@ -91,7 +91,8 @@ class TestNodeDetailPage:
assert response.status_code == 200
# Should display node details
assert "Node One" in response.text
assert "REPEATER" in response.text
# Node type is shown as emoji with title attribute
assert 'title="Repeater"' in response.text
def test_node_detail_displays_public_key(
self, client: TestClient, mock_http_client: MockHttpClient