diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 44fb44e..55a57ea 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -5,7 +5,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession @@ -15,6 +15,16 @@ from meshcore_hub.common.schemas.messages import AdvertisementList, Advertisemen router = APIRouter() +def _get_friendly_name(node: Optional[Node]) -> Optional[str]: + """Extract friendly_name tag from a node's tags.""" + if not node or not node.tags: + return None + for tag in node.tags: + if tag.key == "friendly_name": + return tag.value + return None + + @router.get("", response_model=AdvertisementList) async def list_advertisements( _: RequireRead, @@ -29,13 +39,24 @@ async def list_advertisements( offset: int = Query(0, ge=0, description="Page offset"), ) -> AdvertisementList: """List advertisements with filtering and pagination.""" - # Alias for receiver node join + # Aliases for node joins ReceiverNode = aliased(Node) + SourceNode = aliased(Node) - # Build query with receiver node join - query = select( - Advertisement, ReceiverNode.public_key.label("receiver_pk") - ).outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id) + # Build query with both receiver and source node joins + query = ( + select( + Advertisement, + ReceiverNode.public_key.label("receiver_pk"), + ReceiverNode.name.label("receiver_name"), + ReceiverNode.id.label("receiver_id"), + SourceNode.name.label("source_name"), + SourceNode.id.label("source_id"), + SourceNode.adv_type.label("source_adv_type"), + ) + .outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id) + .outerjoin(SourceNode, Advertisement.node_id == SourceNode.id) + ) if public_key: query = query.where(Advertisement.public_key == public_key) @@ -59,17 +80,39 @@ async def list_advertisements( # Execute results = session.execute(query).all() - # Build response with received_by + # Collect node IDs to fetch tags + node_ids = set() + for row in results: + if row.receiver_id: + node_ids.add(row.receiver_id) + if row.source_id: + node_ids.add(row.source_id) + + # Fetch nodes with tags + nodes_by_id: dict[str, Node] = {} + if node_ids: + nodes_query = ( + select(Node).where(Node.id.in_(node_ids)).options(selectinload(Node.tags)) + ) + nodes = session.execute(nodes_query).scalars().all() + nodes_by_id = {n.id: n for n in nodes} + + # Build response with node details items = [] - for adv, receiver_pk in results: + for row in results: + adv = row[0] + receiver_node = nodes_by_id.get(row.receiver_id) if row.receiver_id else None + source_node = nodes_by_id.get(row.source_id) if row.source_id else None + data = { - "id": adv.id, - "receiver_node_id": adv.receiver_node_id, - "received_by": receiver_pk, - "node_id": adv.node_id, + "received_by": row.receiver_pk, + "receiver_name": row.receiver_name, + "receiver_friendly_name": _get_friendly_name(receiver_node), "public_key": adv.public_key, "name": adv.name, - "adv_type": adv.adv_type, + "node_name": row.source_name, + "node_friendly_name": _get_friendly_name(source_node), + "adv_type": adv.adv_type or row.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, "created_at": adv.created_at, @@ -92,9 +135,19 @@ async def get_advertisement( ) -> AdvertisementRead: """Get a single advertisement by ID.""" ReceiverNode = aliased(Node) + SourceNode = aliased(Node) query = ( - select(Advertisement, ReceiverNode.public_key.label("receiver_pk")) + select( + Advertisement, + ReceiverNode.public_key.label("receiver_pk"), + ReceiverNode.name.label("receiver_name"), + ReceiverNode.id.label("receiver_id"), + SourceNode.name.label("source_name"), + SourceNode.id.label("source_id"), + SourceNode.adv_type.label("source_adv_type"), + ) .outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id) + .outerjoin(SourceNode, Advertisement.node_id == SourceNode.id) .where(Advertisement.id == advertisement_id) ) result = session.execute(query).one_or_none() @@ -102,15 +155,35 @@ async def get_advertisement( if not result: raise HTTPException(status_code=404, detail="Advertisement not found") - adv, receiver_pk = result + adv = result[0] + + # Fetch nodes with tags for friendly names + node_ids = [] + if result.receiver_id: + node_ids.append(result.receiver_id) + if result.source_id: + node_ids.append(result.source_id) + + nodes_by_id: dict[str, Node] = {} + if node_ids: + nodes_query = ( + select(Node).where(Node.id.in_(node_ids)).options(selectinload(Node.tags)) + ) + nodes = session.execute(nodes_query).scalars().all() + nodes_by_id = {n.id: n for n in nodes} + + receiver_node = nodes_by_id.get(result.receiver_id) if result.receiver_id else None + source_node = nodes_by_id.get(result.source_id) if result.source_id else None + data = { - "id": adv.id, - "receiver_node_id": adv.receiver_node_id, - "received_by": receiver_pk, - "node_id": adv.node_id, + "received_by": result.receiver_pk, + "receiver_name": result.receiver_name, + "receiver_friendly_name": _get_friendly_name(receiver_node), "public_key": adv.public_key, "name": adv.name, - "adv_type": adv.adv_type, + "node_name": result.source_name, + "node_friendly_name": _get_friendly_name(source_node), + "adv_type": adv.adv_type or result.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, "created_at": adv.created_at, diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 3711255..f85f438 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -9,7 +9,11 @@ from sqlalchemy import func, select from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession from meshcore_hub.common.models import Advertisement, Message, Node, NodeTag -from meshcore_hub.common.schemas.messages import DashboardStats, RecentAdvertisement +from meshcore_hub.common.schemas.messages import ( + ChannelMessage, + DashboardStats, + RecentAdvertisement, +) router = APIRouter() @@ -123,6 +127,55 @@ async def get_stats( int(channel): int(count) for channel, count in channel_results } + # Get latest 5 messages for each channel that has messages + channel_messages: dict[int, list[ChannelMessage]] = {} + for channel_idx, _ in channel_results: + messages_query = ( + select(Message) + .where(Message.message_type == "channel") + .where(Message.channel_idx == channel_idx) + .order_by(Message.received_at.desc()) + .limit(5) + ) + channel_msgs = session.execute(messages_query).scalars().all() + + # Look up sender names for these messages + msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix] + msg_sender_names: dict[str, str] = {} + msg_friendly_names: dict[str, str] = {} + if msg_prefixes: + for prefix in set(msg_prefixes): + sender_node_query = select(Node.public_key, Node.name).where( + Node.public_key.startswith(prefix) + ) + for public_key, name in session.execute(sender_node_query).all(): + if name: + msg_sender_names[public_key[:12]] = name + + sender_friendly_query = ( + select(Node.public_key, NodeTag.value) + .join(NodeTag, Node.id == NodeTag.node_id) + .where(Node.public_key.startswith(prefix)) + .where(NodeTag.key == "friendly_name") + ) + for public_key, value in session.execute(sender_friendly_query).all(): + msg_friendly_names[public_key[:12]] = value + + channel_messages[int(channel_idx)] = [ + ChannelMessage( + text=m.text, + sender_name=( + msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None + ), + sender_friendly_name=( + msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None + ), + pubkey_prefix=m.pubkey_prefix, + received_at=m.received_at, + ) + for m in channel_msgs + ] + return DashboardStats( total_nodes=total_nodes, active_nodes=active_nodes, @@ -132,6 +185,7 @@ async def get_stats( advertisements_24h=advertisements_24h, recent_advertisements=recent_advertisements, channel_message_counts=channel_message_counts, + channel_messages=channel_messages, ) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 98bfc04..79a461a 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -5,7 +5,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession @@ -15,6 +15,16 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead router = APIRouter() +def _get_friendly_name(node: Optional[Node]) -> Optional[str]: + """Extract friendly_name tag from a node's tags.""" + if not node or not node.tags: + return None + for tag in node.tags: + if tag.key == "friendly_name": + return tag.value + return None + + @router.get("", response_model=MessageList) async def list_messages( _: RequireRead, @@ -36,9 +46,12 @@ async def list_messages( ReceiverNode = aliased(Node) # Build query with receiver node join - query = select(Message, ReceiverNode.public_key.label("receiver_pk")).outerjoin( - ReceiverNode, Message.receiver_node_id == ReceiverNode.id - ) + query = select( + Message, + ReceiverNode.public_key.label("receiver_pk"), + ReceiverNode.name.label("receiver_name"), + ReceiverNode.id.label("receiver_id"), + ).outerjoin(ReceiverNode, Message.receiver_node_id == ReceiverNode.id) if message_type: query = query.where(Message.message_type == message_type) @@ -96,13 +109,39 @@ async def list_messages( for public_key, value in session.execute(friendly_name_query).all(): friendly_names[public_key[:12]] = value + # Collect receiver node IDs to fetch tags + receiver_ids = set() + for row in results: + if row.receiver_id: + receiver_ids.add(row.receiver_id) + + # Fetch receiver nodes with tags + receivers_by_id: dict[str, Node] = {} + if receiver_ids: + receivers_query = ( + select(Node) + .where(Node.id.in_(receiver_ids)) + .options(selectinload(Node.tags)) + ) + receivers = session.execute(receivers_query).scalars().all() + receivers_by_id = {n.id: n for n in receivers} + # Build response with sender info and received_by items = [] - for m, receiver_pk in results: + for row in results: + m = row[0] + receiver_pk = row.receiver_pk + receiver_name = row.receiver_name + receiver_node = ( + receivers_by_id.get(row.receiver_id) if row.receiver_id else None + ) + msg_dict = { "id": m.id, "receiver_node_id": m.receiver_node_id, "received_by": receiver_pk, + "receiver_name": receiver_name, + "receiver_friendly_name": _get_friendly_name(receiver_node), "message_type": m.message_type, "pubkey_prefix": m.pubkey_prefix, "sender_name": ( diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index b57a526..feeeb02 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -12,6 +12,10 @@ class MessageRead(BaseModel): received_by: Optional[str] = Field( default=None, description="Receiving interface node public key" ) + receiver_name: Optional[str] = Field(default=None, description="Receiver node name") + receiver_friendly_name: Optional[str] = Field( + default=None, description="Receiver friendly name from tags" + ) message_type: str = Field(..., description="Message type (contact, channel)") pubkey_prefix: Optional[str] = Field( default=None, description="Sender's public key prefix (12 chars)" @@ -84,8 +88,18 @@ class AdvertisementRead(BaseModel): received_by: Optional[str] = Field( default=None, description="Receiving interface node public key" ) + receiver_name: Optional[str] = Field(default=None, description="Receiver node name") + receiver_friendly_name: Optional[str] = Field( + default=None, description="Receiver friendly name from tags" + ) public_key: str = Field(..., description="Advertised public key") name: Optional[str] = Field(default=None, description="Advertised name") + node_name: Optional[str] = Field( + default=None, description="Node name from nodes table" + ) + node_friendly_name: Optional[str] = Field( + default=None, description="Node friendly name from tags" + ) adv_type: Optional[str] = Field(default=None, description="Node type") flags: Optional[int] = Field(default=None, description="Capability flags") received_at: datetime = Field(..., description="When received") @@ -173,6 +187,20 @@ class RecentAdvertisement(BaseModel): received_at: datetime = Field(..., description="When received") +class ChannelMessage(BaseModel): + """Schema for a channel message summary.""" + + text: str = Field(..., description="Message text") + sender_name: Optional[str] = Field(default=None, description="Sender name") + sender_friendly_name: Optional[str] = Field( + default=None, description="Sender friendly name" + ) + pubkey_prefix: Optional[str] = Field( + default=None, description="Sender public key prefix" + ) + received_at: datetime = Field(..., description="When received") + + class DashboardStats(BaseModel): """Schema for dashboard statistics.""" @@ -191,3 +219,7 @@ class DashboardStats(BaseModel): default_factory=dict, description="Message count per channel", ) + channel_messages: dict[int, list[ChannelMessage]] = Field( + default_factory=dict, + description="Recent messages per channel (up to 5 each)", + ) diff --git a/src/meshcore_hub/web/routes/__init__.py b/src/meshcore_hub/web/routes/__init__.py index 4d35f07..43736b4 100644 --- a/src/meshcore_hub/web/routes/__init__.py +++ b/src/meshcore_hub/web/routes/__init__.py @@ -6,6 +6,7 @@ from meshcore_hub.web.routes.home import router as home_router from meshcore_hub.web.routes.network import router as network_router from meshcore_hub.web.routes.nodes import router as nodes_router from meshcore_hub.web.routes.messages import router as messages_router +from meshcore_hub.web.routes.advertisements import router as advertisements_router from meshcore_hub.web.routes.map import router as map_router from meshcore_hub.web.routes.members import router as members_router @@ -17,6 +18,7 @@ web_router.include_router(home_router) web_router.include_router(network_router) web_router.include_router(nodes_router) web_router.include_router(messages_router) +web_router.include_router(advertisements_router) web_router.include_router(map_router) web_router.include_router(members_router) diff --git a/src/meshcore_hub/web/routes/advertisements.py b/src/meshcore_hub/web/routes/advertisements.py new file mode 100644 index 0000000..a51a80b --- /dev/null +++ b/src/meshcore_hub/web/routes/advertisements.py @@ -0,0 +1,64 @@ +"""Advertisements page route.""" + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +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"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(50, ge=1, le=100, description="Items per page"), +) -> HTMLResponse: + """Render the advertisements list page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Calculate offset + offset = (page - 1) * limit + + # Build query params + params: dict[str, int | str] = {"limit": limit, "offset": offset} + if public_key: + params["public_key"] = public_key + + # Fetch advertisements from API + advertisements = [] + total = 0 + + try: + response = await request.app.state.http_client.get( + "/api/v1/advertisements", params=params + ) + if response.status_code == 200: + data = response.json() + advertisements = data.get("items", []) + total = data.get("total", 0) + except Exception as e: + logger.warning(f"Failed to fetch advertisements from API: {e}") + context["api_error"] = str(e) + + # Calculate pagination + total_pages = (total + limit - 1) // limit if total > 0 else 1 + + context.update( + { + "advertisements": advertisements, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "public_key": public_key or "", + } + ) + + return templates.TemplateResponse("advertisements.html", context) diff --git a/src/meshcore_hub/web/routes/home.py b/src/meshcore_hub/web/routes/home.py index 03a37cf..dc2b852 100644 --- a/src/meshcore_hub/web/routes/home.py +++ b/src/meshcore_hub/web/routes/home.py @@ -1,10 +1,13 @@ """Home page route.""" +import logging + from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from meshcore_hub.web.app import get_network_context, get_templates +logger = logging.getLogger(__name__) router = APIRouter() @@ -15,4 +18,24 @@ async def home(request: Request) -> HTMLResponse: context = get_network_context(request) context["request"] = request + # Fetch stats from API + stats = { + "total_nodes": 0, + "active_nodes": 0, + "total_messages": 0, + "messages_today": 0, + "total_advertisements": 0, + "advertisements_24h": 0, + } + + try: + response = await request.app.state.http_client.get("/api/v1/dashboard/stats") + if response.status_code == 200: + stats = response.json() + except Exception as e: + logger.warning(f"Failed to fetch stats from API: {e}") + context["api_error"] = str(e) + + context["stats"] = stats + return templates.TemplateResponse("home.html", context) diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html new file mode 100644 index 0000000..d3c5df8 --- /dev/null +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Advertisements{% endblock %} + +{% block content %} +
| Node | +Type | +Received By | +Time | +
|---|---|---|---|
|
+
+ {% if ad.node_friendly_name or ad.node_name or ad.name %}
+ {{ ad.node_friendly_name or ad.node_name or ad.name }}
+ {{ ad.public_key[:16] }}...
+ {% else %}
+ {{ ad.public_key[:16] }}...
+ {% endif %}
+
+ |
+ + {% if ad.adv_type and ad.adv_type|lower == 'chat' %} + 💬 + {% elif ad.adv_type and ad.adv_type|lower == 'repeater' %} + 📡 + {% elif ad.adv_type and ad.adv_type|lower == 'room' %} + 🪧 + {% elif ad.adv_type %} + 📍 + {% else %} + - + {% endif %} + | +
+ {% if ad.received_by %}
+
+ {% if ad.receiver_friendly_name or ad.receiver_name %}
+ {{ ad.receiver_friendly_name or ad.receiver_name }}
+ {{ ad.received_by[:16] }}...
+ {% else %}
+ {{ ad.received_by[:16] }}...
+ {% endif %}
+
+ {% else %}
+ -
+ {% endif %}
+ |
+ + {{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }} + | +
| No advertisements found. | +|||
Powered by MeshCore Hub v{{ version }}
+Powered by MeshCore Hub v{{ version }}
diff --git a/src/meshcore_hub/web/templates/home.html b/src/meshcore_hub/web/templates/home.html index 4fea129..4a9d89d 100644 --- a/src/meshcore_hub/web/templates/home.html +++ b/src/meshcore_hub/web/templates/home.html @@ -3,17 +3,17 @@ {% block title %}{{ network_name }} - Home{% endblock %} {% block content %} -{{ network_city }}, {{ network_country }}
+{{ network_city }}, {{ network_country }}
{% endif %} {% if network_welcome_text %} -{{ network_welcome_text }}
+{{ network_welcome_text }}
{% else %} -+
Welcome to the {{ network_name }} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history.
@@ -23,26 +23,83 @@ - View Network Stats + Dashboard - Browse Nodes + Nodes - + - View Map + Advertisements + + + + Messages| Time | Type | +Time | From/Channel | Message | Receiver | SNR | -Hops | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| - {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} - | +||||||||||||
| {% if msg.message_type == 'channel' %} Channel @@ -75,7 +71,10 @@ Direct {% endif %} | -+ | + {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} + | +{% if msg.message_type == 'channel' %} CH{{ msg.channel_idx }} {% else %} @@ -86,34 +85,32 @@ {% endif %} {% endif %} | -- {{ msg.text or '-' }} - | -+ | {{ msg.text or '-' }} | +
{% if msg.received_by %}
- {{ msg.received_by[:8] }}...
+
+ {% if msg.receiver_friendly_name or msg.receiver_name %}
+ {{ msg.receiver_friendly_name or msg.receiver_name }}
+ {{ msg.received_by[:16] }}...
+ {% else %}
+ {{ msg.received_by[:16] }}...
+ {% endif %}
+
{% else %}
-
{% endif %}
|
- + | {% if msg.snr is not none %} {{ "%.1f"|format(msg.snr) }} {% else %} - {% endif %} | -- {% if msg.hops is not none %} - {{ msg.hops }} - {% else %} - - - {% endif %} - | ||
| No messages found. | +No messages found. | |||||||||||
| Channel | -Count | -
|---|---|
| Channel {{ channel }} | -{{ count }} | -
No channel messages recorded yet.
- {% endif %} + {% endif %} diff --git a/src/meshcore_hub/web/templates/nodes.html b/src/meshcore_hub/web/templates/nodes.html index e81d09b..1636fca 100644 --- a/src/meshcore_hub/web/templates/nodes.html +++ b/src/meshcore_hub/web/templates/nodes.html @@ -49,9 +49,8 @@| Name | +Node | Type | -Public Key | Last Seen | Tags | |||
|---|---|---|---|---|---|---|---|---|
| - {{ ns.friendly_name or node.name or '-' }} + |
+
+ {% if ns.friendly_name or node.name %}
+ {{ ns.friendly_name or node.name }}
+ {{ node.public_key[:16] }}...
+ {% else %}
+ {{ node.public_key[:16] }}...
+ {% endif %}
+
|
{% if node.adv_type and node.adv_type|lower == 'chat' %} @@ -81,10 +87,7 @@ - {% endif %} | -- {{ node.public_key[:16] }}... - | -+ | {% if node.last_seen %} {{ node.last_seen[:19].replace('T', ' ') }} {% else %} @@ -108,7 +111,7 @@ | |||
| No nodes found. | +No nodes found. | |||||||