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 %} +
+

Advertisements

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + {% for ad in advertisements %} + + + + + + + {% else %} + + + + {% endfor %} + +
NodeTypeReceived ByTime
+ + {% 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.
+
+ + +{% 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..5cdd510 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
  • @@ -112,7 +114,7 @@ GitHub {% endif %}

    -

    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_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..8fe1425 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -53,21 +53,17 @@ - + - {% for msg in messages %} - - + - + - - + - - {% else %} - + {% endfor %} diff --git a/src/meshcore_hub/web/templates/network.html b/src/meshcore_hub/web/templates/network.html index 0e806a1..87d5fe8 100644 --- a/src/meshcore_hub/web/templates/network.html +++ b/src/meshcore_hub/web/templates/network.html @@ -130,39 +130,37 @@ - + + {% if stats.channel_messages %}

    - Channel Messages + Recent Channel Messages

    - {% if stats.channel_message_counts %} -
    -
    Time TypeTime From/Channel Message Receiver SNRHops
    - {{ 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.
    - - - - - - - - {% 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 %} +
    + {{ 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 %} 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 %}
    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.