diff --git a/README.md b/README.md index 80096f2..84a430f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MeshCore Hub -Python 3.11+ platform for managing and orchestrating MeshCore mesh networks. +Python 3.13+ platform for managing and orchestrating MeshCore mesh networks. ![MeshCore Hub Web Dashboard](docs/images/web.png) diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 7ed44a7..0ba0731 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -96,6 +96,9 @@ async def list_advertisements( received_by: Optional[str] = Query( None, description="Filter by receiver node public key" ), + member_id: Optional[str] = Query( + None, description="Filter by member_id tag value of source node" + ), since: Optional[datetime] = Query(None, description="Start timestamp"), until: Optional[datetime] = Query(None, description="End timestamp"), limit: int = Query(50, ge=1, le=100, description="Page size"), @@ -143,6 +146,16 @@ async def list_advertisements( if received_by: query = query.where(ReceiverNode.public_key == received_by) + if member_id: + # Filter advertisements from nodes that have a member_id tag with the specified value + query = query.where( + SourceNode.id.in_( + select(NodeTag.node_id).where( + NodeTag.key == "member_id", NodeTag.value == member_id + ) + ) + ) + if since: query = query.where(Advertisement.received_at >= since) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index a8ca922..2e895b4 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -22,6 +22,7 @@ async def list_nodes( None, description="Search in name tag, node name, or public key" ), adv_type: Optional[str] = Query(None, description="Filter by advertisement type"), + member_id: Optional[str] = Query(None, description="Filter by member_id tag value"), limit: int = Query(50, ge=1, le=500, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> NodeList: @@ -48,6 +49,16 @@ async def list_nodes( if adv_type: query = query.where(Node.adv_type == adv_type) + if member_id: + # Filter nodes that have a member_id tag with the specified value + query = query.where( + Node.id.in_( + select(NodeTag.node_id).where( + NodeTag.key == "member_id", NodeTag.value == member_id + ) + ) + ) + # Get total count count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 diff --git a/src/meshcore_hub/web/routes/advertisements.py b/src/meshcore_hub/web/routes/advertisements.py index 2369210..a6c4000 100644 --- a/src/meshcore_hub/web/routes/advertisements.py +++ b/src/meshcore_hub/web/routes/advertisements.py @@ -15,6 +15,8 @@ router = APIRouter() async def advertisements_list( request: Request, search: str | None = Query(None, description="Search term"), + member_id: str | None = Query(None, description="Filter by member"), + public_key: str | None = Query(None, description="Filter by node public key"), page: int = Query(1, ge=1, description="Page number"), limit: int = Query(50, ge=1, le=100, description="Items per page"), ) -> HTMLResponse: @@ -30,12 +32,41 @@ async def advertisements_list( params: dict[str, int | str] = {"limit": limit, "offset": offset} if search: params["search"] = search + if member_id: + params["member_id"] = member_id + if public_key: + params["public_key"] = public_key # Fetch advertisements from API advertisements = [] total = 0 + members = [] + nodes = [] try: + # Fetch members for dropdown + members_response = await request.app.state.http_client.get( + "/api/v1/members", params={"limit": 100} + ) + if members_response.status_code == 200: + members = members_response.json().get("items", []) + + # Fetch nodes for dropdown + nodes_response = await request.app.state.http_client.get( + "/api/v1/nodes", params={"limit": 500} + ) + if nodes_response.status_code == 200: + nodes = nodes_response.json().get("items", []) + + # Sort nodes alphabetically by display name + def get_node_display_name(node: dict) -> str: + for tag in node.get("tags") or []: + if tag.get("key") == "name": + return str(tag.get("value", "")).lower() + return str(node.get("name") or node.get("public_key", "")).lower() + + nodes.sort(key=get_node_display_name) + response = await request.app.state.http_client.get( "/api/v1/advertisements", params=params ) @@ -58,6 +89,10 @@ async def advertisements_list( "limit": limit, "total_pages": total_pages, "search": search or "", + "member_id": member_id or "", + "public_key": public_key or "", + "members": members, + "nodes": nodes, } ) diff --git a/src/meshcore_hub/web/routes/nodes.py b/src/meshcore_hub/web/routes/nodes.py index 5810733..3a7f907 100644 --- a/src/meshcore_hub/web/routes/nodes.py +++ b/src/meshcore_hub/web/routes/nodes.py @@ -16,6 +16,7 @@ async def nodes_list( request: Request, search: str | None = Query(None, description="Search term"), adv_type: str | None = Query(None, description="Filter by node type"), + member_id: str | None = Query(None, description="Filter by member"), page: int = Query(1, ge=1, description="Page number"), limit: int = Query(20, ge=1, le=100, description="Items per page"), ) -> HTMLResponse: @@ -33,12 +34,22 @@ async def nodes_list( params["search"] = search if adv_type: params["adv_type"] = adv_type + if member_id: + params["member_id"] = member_id # Fetch nodes from API nodes = [] total = 0 + members = [] try: + # Fetch members for dropdown + members_response = await request.app.state.http_client.get( + "/api/v1/members", params={"limit": 100} + ) + if members_response.status_code == 200: + members = members_response.json().get("items", []) + response = await request.app.state.http_client.get( "/api/v1/nodes", params=params ) @@ -62,6 +73,8 @@ async def nodes_list( "total_pages": total_pages, "search": search or "", "adv_type": adv_type or "", + "member_id": member_id or "", + "members": members, } ) diff --git a/src/meshcore_hub/web/templates/_macros.html b/src/meshcore_hub/web/templates/_macros.html new file mode 100644 index 0000000..a994ef9 --- /dev/null +++ b/src/meshcore_hub/web/templates/_macros.html @@ -0,0 +1,47 @@ +{# Reusable macros for templates #} + +{# + Pagination macro + + Parameters: + - page: Current page number + - total_pages: Total number of pages + - params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50}) +#} +{% macro pagination(page, total_pages, params={}) %} +{% if total_pages > 1 %} +{% set query_parts = [] %} +{% for key, value in params.items() %} +{% if value is not none and value != '' %} +{% set _ = query_parts.append(key ~ '=' ~ value) %} +{% endif %} +{% endfor %} +{% set base_query = query_parts|join('&') %} +{% set query_prefix = '&' if base_query else '' %} +
+
+ {% 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 %} +{% endmacro %} diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html index 9d078fc..08dc9c7 100644 --- a/src/meshcore_hub/web/templates/advertisements.html +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_macros.html" import pagination %} {% block title %}{{ network_name }} - Advertisements{% endblock %} @@ -27,14 +28,87 @@ - - Clear + {% if nodes %} +
+ + +
+ {% endif %} + {% if members %} +
+ + +
+ {% endif %} +
+ + Clear +
- -
+ +
+ {% for ad in advertisements %} + +
+
+
+ {% 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' %}🪧{% else %}📍{% endif %} +
+ {% if ad.node_tag_name or ad.node_name or ad.name %} +
{{ ad.node_tag_name or ad.node_name or ad.name }}
+
{{ ad.public_key[:16] }}...
+ {% else %} +
{{ ad.public_key[:16] }}...
+ {% endif %} +
+
+
+
+ {{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }} +
+ {% if ad.receivers and ad.receivers|length >= 1 %} +
+ {% for recv in ad.receivers %} + 📡 + {% endfor %} +
+ {% elif ad.received_by %} + 📡 + {% endif %} +
+
+
+
+ {% else %} +
No advertisements found.
+ {% endfor %} +
+ + + - -{% 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 %} +{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }} {% endblock %} diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index f744a04..36b6cc8 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_macros.html" import pagination %} {% block title %}{{ network_name }} - Messages{% endblock %} @@ -42,8 +43,10 @@ {% endfor %}
- - Clear +
+ + Clear +
@@ -162,32 +165,5 @@ - -{% 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 %} +{{ pagination(page, total_pages, {"message_type": message_type, "channel_idx": channel_idx, "limit": limit}) }} {% endblock %} diff --git a/src/meshcore_hub/web/templates/nodes.html b/src/meshcore_hub/web/templates/nodes.html index 3d44fb5..881404d 100644 --- a/src/meshcore_hub/web/templates/nodes.html +++ b/src/meshcore_hub/web/templates/nodes.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_macros.html" import pagination %} {% block title %}{{ network_name }} - Nodes{% endblock %} @@ -38,14 +39,79 @@ - - Clear + {% if members %} +
+ + +
+ {% endif %} +
+ + Clear +
- -
+ +
+ {% for node in nodes %} + {% set ns = namespace(tag_name=none) %} + {% for tag in node.tags or [] %} + {% if tag.key == 'name' %} + {% set ns.tag_name = tag.value %} + {% endif %} + {% endfor %} + +
+
+
+ {% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %} +
+ {% if ns.tag_name or node.name %} +
{{ ns.tag_name or node.name }}
+
{{ node.public_key[:16] }}...
+ {% else %} +
{{ node.public_key[:16] }}...
+ {% endif %} +
+
+
+
+ {% if node.last_seen %} + {{ node.last_seen[:10] }} + {% else %} + - + {% endif %} +
+ {% if node.tags %} +
+ {% for tag in node.tags[:2] %} + {{ tag.key }} + {% endfor %} + {% if node.tags|length > 2 %} + +{{ node.tags|length - 2 }} + {% endif %} +
+ {% endif %} +
+
+
+
+ {% else %} +
No nodes found.
+ {% endfor %} +
+ + + - -{% 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 %} +{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }} {% endblock %}