From b4e7d45cf6acbd5bcc4f35efdf5db94efd5321fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 17:14:26 +0000 Subject: [PATCH 01/11] UI improvements: smaller hero, stats bar, advertisements page, messages fixes - Reduce hero section size and add stats bar with node/message counts - Add new Advertisements page with public key filtering - Update hero navigation buttons: Dashboard, Nodes, Advertisements, Messages - Add Advertisements to main navigation menu - Remove Hops column from messages list (always empty) - Display full message text with proper multi-line wrapping --- src/meshcore_hub/web/routes/__init__.py | 2 + src/meshcore_hub/web/routes/advertisements.py | 64 +++++++++ src/meshcore_hub/web/routes/home.py | 23 ++++ .../web/templates/advertisements.html | 122 ++++++++++++++++++ src/meshcore_hub/web/templates/base.html | 2 + src/meshcore_hub/web/templates/home.html | 79 ++++++++++-- src/meshcore_hub/web/templates/messages.html | 20 +-- 7 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 src/meshcore_hub/web/routes/advertisements.py create mode 100644 src/meshcore_hub/web/templates/advertisements.html 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..1f8a4d2 --- /dev/null +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Advertisements{% endblock %} + +{% block content %} +
+

Advertisements

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + + {% for ad in advertisements %} + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeNameTypePublic KeyReceiver
+ {{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }} + + + {{ ad.name or '-' }} + + + {% 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 %} + + + {{ ad.public_key[:16] }}... + + + {% if ad.received_by %} + {{ ad.received_by[:8] }}... + {% else %} + - + {% endif %} +
No advertisements found.
+
+ + +{% if total_pages > 1 %} +
+
+ {% if page > 1 %} + Previous + {% else %} + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + + {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} + {{ p }} + {% elif p == 2 or p == total_pages - 1 %} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + Next + {% else %} + + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html index 8711287..972578f 100644 --- a/src/meshcore_hub/web/templates/base.html +++ b/src/meshcore_hub/web/templates/base.html @@ -58,6 +58,7 @@
  • Home
  • Network
  • Nodes
  • +
  • Advertisements
  • Messages
  • Map
  • Members
  • @@ -75,6 +76,7 @@
  • Home
  • Network
  • Nodes
  • +
  • Advertisements
  • Messages
  • Map
  • Members
  • 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_name }}

    +

    {{ network_name }}

    {% if network_city and network_country %} -

    {{ 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
    -
    + +
    + +
    +
    + + + +
    +
    Total Nodes
    +
    {{ stats.total_nodes }}
    +
    All discovered nodes
    +
    + + +
    +
    + + + +
    +
    Advertisements
    +
    {{ stats.advertisements_24h }}
    +
    Received in last 24 hours
    +
    + + +
    +
    + + + +
    +
    Total Messages
    +
    {{ stats.total_messages }}
    +
    All time
    +
    + + +
    +
    + + + +
    +
    Messages Today
    +
    {{ stats.messages_today }}
    +
    Last 24 hours
    +
    +
    + +
    diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 2d7471c..55d0b26 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -59,12 +59,11 @@ Message Receiver SNR - Hops {% for msg in messages %} - + {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} @@ -75,7 +74,7 @@ Direct {% endif %} - + {% if msg.message_type == 'channel' %} CH{{ msg.channel_idx }} {% else %} @@ -86,34 +85,27 @@ {% endif %} {% endif %} - + {{ msg.text or '-' }} - + {% if msg.received_by %} {{ msg.received_by[:8] }}... {% else %} - {% endif %} - + {% if msg.snr is not none %} {{ "%.1f"|format(msg.snr) }} {% else %} - {% endif %} - - {% if msg.hops is not none %} - {{ msg.hops }} - {% else %} - - - {% endif %} - {% else %} - No messages found. + No messages found. {% endfor %} From ec7082e01aa41095ba6b265e548bd875b9c6a0e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 17:43:09 +0000 Subject: [PATCH 02/11] Fix message text indentation in messages list Put message content inline with td tag to prevent whitespace-pre-wrap from preserving template indentation. --- src/meshcore_hub/web/templates/messages.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 55d0b26..684443c 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -85,9 +85,7 @@ {% endif %} {% endif %} - - {{ msg.text or '-' }} - + {{ msg.text or '-' }} {% if msg.received_by %} {{ msg.received_by[:8] }}... From ab4a5886dbddb2472f8bbb9f20bd49b41da09627 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 17:53:54 +0000 Subject: [PATCH 03/11] Simplify advertisements table: Node, Received By, Time columns - Remove Name and Type columns (usually null) - Reorder columns: Node first, then Received By, then Time - Link both Node and Received By to their node detail pages - Show node name with public key preview when available --- .../web/templates/advertisements.html | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html index 1f8a4d2..a0c2979 100644 --- a/src/meshcore_hub/web/templates/advertisements.html +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -38,53 +38,40 @@ + + - - - - {% for ad in advertisements %} - - - - + {% else %} - + {% endfor %} From 89c81630c99e52aa9a7899065e69cf17c0b719c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 17:55:59 +0000 Subject: [PATCH 04/11] Link 'Powered by MeshCore Hub' to GitHub repository --- src/meshcore_hub/web/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html index 972578f..5cdd510 100644 --- a/src/meshcore_hub/web/templates/base.html +++ b/src/meshcore_hub/web/templates/base.html @@ -114,7 +114,7 @@ GitHub {% endif %}

    -

    Powered by MeshCore Hub v{{ version }}

    +

    Powered by MeshCore Hub v{{ version }}

    From 3469278fba5e6834fba1324fda957160774ecd2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 17:58:52 +0000 Subject: [PATCH 05/11] Add node name lookups to advertisements list - Join with Node table to get node names and tags for both source and receiver nodes - Display friendly_name (from tags), node_name, or advertised name with priority in that order - Show name with public key preview for both Node and Received By columns --- src/meshcore_hub/api/routes/advertisements.py | 107 +++++++++++++++--- src/meshcore_hub/common/schemas/messages.py | 10 ++ .../web/templates/advertisements.html | 13 ++- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 44fb44e..3f75696 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,23 @@ 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"), + ) + .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,16 +79,38 @@ 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, + "node_name": row.source_name, + "node_friendly_name": _get_friendly_name(source_node), "adv_type": adv.adv_type, "flags": adv.flags, "received_at": adv.received_at, @@ -92,9 +134,18 @@ 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"), + ) .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,14 +153,34 @@ 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, + "node_name": result.source_name, + "node_friendly_name": _get_friendly_name(source_node), "adv_type": adv.adv_type, "flags": adv.flags, "received_at": adv.received_at, diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index b57a526..0ab0a63 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -84,8 +84,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") diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html index a0c2979..b32f7dd 100644 --- a/src/meshcore_hub/web/templates/advertisements.html +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -48,8 +48,8 @@
    NodeReceived By TimeNameTypePublic KeyReceiver
    - {{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }} - + - {{ ad.name or '-' }} + {% if ad.name %} +
    {{ 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 %} - - - {{ ad.public_key[:16] }}... - - {% if ad.received_by %} - {{ ad.received_by[:8] }}... + + {{ ad.received_by[:16] }}... + {% else %} - {% endif %} + {{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }} +
    No advertisements found.No advertisements found.
    - {% if ad.name %} -
    {{ ad.name }}
    + {% 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] }}... @@ -58,8 +58,13 @@
    {% if ad.received_by %} - - {{ ad.received_by[:16] }}... + + {% 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 %} - From a44d38dad672d7c1221525f273a36e2d55e058e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:03:14 +0000 Subject: [PATCH 06/11] Update Nodes list to match Advertisements style - Rename Name column to Node - Remove separate Public Key column - Show name with public key prefix below (like Advertisements list) - Add whitespace-nowrap to Last Seen column --- src/meshcore_hub/web/templates/nodes.html | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 @@ - + - @@ -65,8 +64,15 @@ {% endif %} {% endfor %} - - - {% else %} - + {% endfor %} From 5077178a6d53c8834a29dd623ff119e47d2d00c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:04:04 +0000 Subject: [PATCH 07/11] Add Type column with emoji to Advertisements list --- .../web/templates/advertisements.html | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html index b32f7dd..d3c5df8 100644 --- a/src/meshcore_hub/web/templates/advertisements.html +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -39,6 +39,7 @@ + @@ -56,6 +57,19 @@ {% endif %} + {% else %} - + {% endfor %} From 087a3c4c432e57c64f1fb54952503c8cda13fcf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:09:29 +0000 Subject: [PATCH 08/11] Messages list: swap Time/Type columns, add receiver node links - Swap Time and Type columns (Type now first) - Add receiver_name and receiver_friendly_name to MessageRead schema - Update messages API to fetch receiver node names and tags - Make Receiver column a link showing name with public key prefix --- src/meshcore_hub/api/routes/messages.py | 49 ++++++++++++++++++-- src/meshcore_hub/common/schemas/messages.py | 4 ++ src/meshcore_hub/web/templates/messages.html | 19 +++++--- 3 files changed, 61 insertions(+), 11 deletions(-) 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 0ab0a63..105b751 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)" diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 684443c..8fe1425 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -53,8 +53,8 @@
    NameNode TypePublic 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.
    NodeType Received By Time
    + {% 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 %} @@ -76,7 +90,7 @@
    No advertisements found.No advertisements found.
    - + @@ -64,9 +64,6 @@ {% for msg in messages %} - + -
    Time TypeTime From/Channel Message Receiver
    - {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} - {% if msg.message_type == 'channel' %} Channel @@ -74,6 +71,9 @@ Direct {% endif %} + {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} + {% if msg.message_type == 'channel' %} CH{{ msg.channel_idx }} @@ -86,9 +86,16 @@ {% endif %} {{ 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 %} From e3ce1258a890e902588a8c5f2f53947869548ec6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:14:16 +0000 Subject: [PATCH 09/11] Fix advertisements Type column by falling back to source node's adv_type The adv_type from the Advertisement record is often null, but the linked Node has the correct adv_type. Now falls back to source_node.adv_type when adv.adv_type is null. --- src/meshcore_hub/api/routes/advertisements.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 3f75696..55a57ea 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -52,6 +52,7 @@ async def list_advertisements( 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) @@ -111,7 +112,7 @@ async def list_advertisements( "name": adv.name, "node_name": row.source_name, "node_friendly_name": _get_friendly_name(source_node), - "adv_type": adv.adv_type, + "adv_type": adv.adv_type or row.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, "created_at": adv.created_at, @@ -143,6 +144,7 @@ async def get_advertisement( 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) @@ -181,7 +183,7 @@ async def get_advertisement( "name": adv.name, "node_name": result.source_name, "node_friendly_name": _get_friendly_name(source_node), - "adv_type": adv.adv_type, + "adv_type": adv.adv_type or result.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, "created_at": adv.created_at, From 0d14ed0cccb1f14ed9dba55daf3b8383def08330 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:20:49 +0000 Subject: [PATCH 10/11] Add latest channel messages to Network dashboard Replace the channel counts table with actual recent messages per channel: - Added ChannelMessage schema for channel message summaries - Dashboard API now fetches latest 5 messages for each channel with sender name lookups - Network page displays messages grouped by channel with sender names and timestamps - Only shows channels that have messages --- src/meshcore_hub/api/routes/dashboard.py | 56 ++++++++++++++++++++- src/meshcore_hub/common/schemas/messages.py | 18 +++++++ src/meshcore_hub/web/templates/network.html | 53 +++++++++++-------- 3 files changed, 104 insertions(+), 23 deletions(-) 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/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 105b751..feeeb02 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -187,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.""" @@ -205,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/templates/network.html b/src/meshcore_hub/web/templates/network.html index 0e806a1..a590ae7 100644 --- a/src/meshcore_hub/web/templates/network.html +++ b/src/meshcore_hub/web/templates/network.html @@ -130,39 +130,48 @@ - + + {% if stats.channel_messages %}

    - Channel Messages + Recent Channel Messages

    - {% if stats.channel_message_counts %} -
    - - - - - - - - - {% for channel, count in stats.channel_message_counts.items() %} - - - - +
    + {% for channel, messages in stats.channel_messages.items() %} +
    +

    + CH{{ channel }} + Channel {{ channel }} +

    +
    + {% for msg in messages %} +
    +
    + + {% if msg.sender_friendly_name or msg.sender_name %} + {{ msg.sender_friendly_name or msg.sender_name }} + {% elif msg.pubkey_prefix %} + {{ msg.pubkey_prefix[:8] }} + {% else %} + Unknown + {% endif %} + + {{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }} +
    +
    {{ msg.text }}
    +
    {% endfor %} -
    -
    ChannelCount
    Channel {{ channel }}{{ count }}
    +
    +
    + {% endfor %}
    - {% else %} -

    No channel messages recorded yet.

    - {% endif %} + {% endif %} From 995b066b0d62f759c9298538a972ce1872ca434d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 18:23:33 +0000 Subject: [PATCH 11/11] Remove sender name from channel messages summary, keep only timestamp --- src/meshcore_hub/web/templates/network.html | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/meshcore_hub/web/templates/network.html b/src/meshcore_hub/web/templates/network.html index a590ae7..87d5fe8 100644 --- a/src/meshcore_hub/web/templates/network.html +++ b/src/meshcore_hub/web/templates/network.html @@ -150,19 +150,8 @@
    {% for msg in messages %}
    -
    - - {% if msg.sender_friendly_name or msg.sender_name %} - {{ msg.sender_friendly_name or msg.sender_name }} - {% elif msg.pubkey_prefix %} - {{ msg.pubkey_prefix[:8] }} - {% else %} - Unknown - {% endif %} - - {{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }} -
    -
    {{ msg.text }}
    + {{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }} + {{ msg.text }}
    {% endfor %}