diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 378b505..e70f3bf 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -5,10 +5,11 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select +from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Advertisement +from meshcore_hub.common.models import Advertisement, Node from meshcore_hub.common.schemas.messages import AdvertisementList, AdvertisementRead router = APIRouter() @@ -19,18 +20,29 @@ async def list_advertisements( _: RequireRead, session: DbSession, public_key: Optional[str] = Query(None, description="Filter by public key"), + receiver_public_key: Optional[str] = Query( + None, description="Filter by receiver node public key" + ), 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"), offset: int = Query(0, ge=0, description="Page offset"), ) -> AdvertisementList: """List advertisements with filtering and pagination.""" - # Build query - query = select(Advertisement) + # Alias for receiver node join + ReceiverNode = 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) if public_key: query = query.where(Advertisement.public_key == public_key) + if receiver_public_key: + query = query.where(ReceiverNode.public_key == receiver_public_key) + if since: query = query.where(Advertisement.received_at >= since) @@ -45,10 +57,27 @@ async def list_advertisements( query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit) # Execute - advertisements = session.execute(query).scalars().all() + results = session.execute(query).all() + + # Build response with receiver_public_key + items = [] + for adv, receiver_pk in results: + data = { + "id": adv.id, + "receiver_node_id": adv.receiver_node_id, + "receiver_public_key": receiver_pk, + "node_id": adv.node_id, + "public_key": adv.public_key, + "name": adv.name, + "adv_type": adv.adv_type, + "flags": adv.flags, + "received_at": adv.received_at, + "created_at": adv.created_at, + } + items.append(AdvertisementRead(**data)) return AdvertisementList( - items=[AdvertisementRead.model_validate(a) for a in advertisements], + items=items, total=total, limit=limit, offset=offset, @@ -62,10 +91,28 @@ async def get_advertisement( advertisement_id: str, ) -> AdvertisementRead: """Get a single advertisement by ID.""" - query = select(Advertisement).where(Advertisement.id == advertisement_id) - advertisement = session.execute(query).scalar_one_or_none() + ReceiverNode = aliased(Node) + query = ( + select(Advertisement, ReceiverNode.public_key.label("receiver_pk")) + .outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id) + .where(Advertisement.id == advertisement_id) + ) + result = session.execute(query).one_or_none() - if not advertisement: + if not result: raise HTTPException(status_code=404, detail="Advertisement not found") - return AdvertisementRead.model_validate(advertisement) + adv, receiver_pk = result + data = { + "id": adv.id, + "receiver_node_id": adv.receiver_node_id, + "receiver_public_key": receiver_pk, + "node_id": adv.node_id, + "public_key": adv.public_key, + "name": adv.name, + "adv_type": adv.adv_type, + "flags": adv.flags, + "received_at": adv.received_at, + "created_at": adv.created_at, + } + return AdvertisementRead(**data) diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 80a44d9..be8124e 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -74,10 +74,20 @@ async def get_stats( .all() ) - # Get friendly_name tags for the advertised nodes + # Get node names and friendly_name tags for the advertised nodes ad_public_keys = [ad.public_key for ad in recent_ads] + node_names: dict[str, str] = {} friendly_names: dict[str, str] = {} if ad_public_keys: + # Get node names from Node table + node_name_query = select(Node.public_key, Node.name).where( + Node.public_key.in_(ad_public_keys) + ) + for public_key, name in session.execute(node_name_query).all(): + if name: + node_names[public_key] = name + + # Get friendly_name tags friendly_name_query = ( select(Node.public_key, NodeTag.value) .join(NodeTag, Node.id == NodeTag.node_id) @@ -90,7 +100,7 @@ async def get_stats( recent_advertisements = [ RecentAdvertisement( public_key=ad.public_key, - name=ad.name, + name=ad.name or node_names.get(ad.public_key), friendly_name=friendly_names.get(ad.public_key), adv_type=ad.adv_type, received_at=ad.received_at, diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index e8b7d2f..1e5e981 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -5,6 +5,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select +from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession @@ -21,6 +22,9 @@ async def list_messages( message_type: Optional[str] = Query(None, description="Filter by message type"), pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"), channel_idx: Optional[int] = Query(None, description="Filter by channel"), + receiver_public_key: Optional[str] = Query( + None, description="Filter by receiver node public key" + ), since: Optional[datetime] = Query(None, description="Start timestamp"), until: Optional[datetime] = Query(None, description="End timestamp"), search: Optional[str] = Query(None, description="Search in message text"), @@ -28,8 +32,13 @@ async def list_messages( offset: int = Query(0, ge=0, description="Page offset"), ) -> MessageList: """List messages with filtering and pagination.""" - # Build query - query = select(Message) + # Alias for receiver node join + 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 + ) if message_type: query = query.where(Message.message_type == message_type) @@ -40,6 +49,9 @@ async def list_messages( if channel_idx is not None: query = query.where(Message.channel_idx == channel_idx) + if receiver_public_key: + query = query.where(ReceiverNode.public_key == receiver_public_key) + if since: query = query.where(Message.received_at >= since) @@ -57,14 +69,24 @@ async def list_messages( query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit) # Execute - messages = session.execute(query).scalars().all() + results = session.execute(query).all() - # Look up friendly_names for senders with pubkey_prefix - pubkey_prefixes = [m.pubkey_prefix for m in messages if m.pubkey_prefix] + # Look up sender names and friendly_names for senders with pubkey_prefix + pubkey_prefixes = [r[0].pubkey_prefix for r in results if r[0].pubkey_prefix] + sender_names: dict[str, str] = {} friendly_names: dict[str, str] = {} if pubkey_prefixes: # Find nodes whose public_key starts with any of these prefixes for prefix in set(pubkey_prefixes): + # Get node name + node_query = select(Node.public_key, Node.name).where( + Node.public_key.startswith(prefix) + ) + for public_key, name in session.execute(node_query).all(): + if name: + sender_names[public_key[:12]] = name + + # Get friendly_name tag friendly_name_query = ( select(Node.public_key, NodeTag.value) .join(NodeTag, Node.id == NodeTag.node_id) @@ -72,17 +94,20 @@ async def list_messages( .where(NodeTag.key == "friendly_name") ) for public_key, value in session.execute(friendly_name_query).all(): - # Map the prefix to the friendly_name friendly_names[public_key[:12]] = value - # Build response with friendly_names + # Build response with sender info and receiver_public_key items = [] - for m in messages: + for m, receiver_pk in results: msg_dict = { "id": m.id, "receiver_node_id": m.receiver_node_id, + "receiver_public_key": receiver_pk, "message_type": m.message_type, "pubkey_prefix": m.pubkey_prefix, + "sender_name": ( + sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None + ), "sender_friendly_name": ( friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None ), @@ -113,10 +138,32 @@ async def get_message( message_id: str, ) -> MessageRead: """Get a single message by ID.""" - query = select(Message).where(Message.id == message_id) - message = session.execute(query).scalar_one_or_none() + ReceiverNode = aliased(Node) + query = ( + select(Message, ReceiverNode.public_key.label("receiver_pk")) + .outerjoin(ReceiverNode, Message.receiver_node_id == ReceiverNode.id) + .where(Message.id == message_id) + ) + result = session.execute(query).one_or_none() - if not message: + if not result: raise HTTPException(status_code=404, detail="Message not found") - return MessageRead.model_validate(message) + message, receiver_pk = result + data = { + "id": message.id, + "receiver_node_id": message.receiver_node_id, + "receiver_public_key": receiver_pk, + "message_type": message.message_type, + "pubkey_prefix": message.pubkey_prefix, + "channel_idx": message.channel_idx, + "text": message.text, + "path_len": message.path_len, + "txt_type": message.txt_type, + "signature": message.signature, + "snr": message.snr, + "sender_timestamp": message.sender_timestamp, + "received_at": message.received_at, + "created_at": message.created_at, + } + return MessageRead(**data) diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py index 0d954cb..3aba139 100644 --- a/src/meshcore_hub/api/routes/telemetry.py +++ b/src/meshcore_hub/api/routes/telemetry.py @@ -5,10 +5,11 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select +from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Telemetry +from meshcore_hub.common.models import Node, Telemetry from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead router = APIRouter() @@ -19,18 +20,29 @@ async def list_telemetry( _: RequireRead, session: DbSession, node_public_key: Optional[str] = Query(None, description="Filter by node"), + receiver_public_key: Optional[str] = Query( + None, description="Filter by receiver node public key" + ), 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"), offset: int = Query(0, ge=0, description="Page offset"), ) -> TelemetryList: """List telemetry records with filtering and pagination.""" - # Build query - query = select(Telemetry) + # Alias for receiver node join + ReceiverNode = aliased(Node) + + # Build query with receiver node join + query = select(Telemetry, ReceiverNode.public_key.label("receiver_pk")).outerjoin( + ReceiverNode, Telemetry.receiver_node_id == ReceiverNode.id + ) if node_public_key: query = query.where(Telemetry.node_public_key == node_public_key) + if receiver_public_key: + query = query.where(ReceiverNode.public_key == receiver_public_key) + if since: query = query.where(Telemetry.received_at >= since) @@ -45,10 +57,25 @@ async def list_telemetry( query = query.order_by(Telemetry.received_at.desc()).offset(offset).limit(limit) # Execute - records = session.execute(query).scalars().all() + results = session.execute(query).all() + + # Build response with receiver_public_key + items = [] + for tel, receiver_pk in results: + data = { + "id": tel.id, + "receiver_node_id": tel.receiver_node_id, + "receiver_public_key": receiver_pk, + "node_id": tel.node_id, + "node_public_key": tel.node_public_key, + "parsed_data": tel.parsed_data, + "received_at": tel.received_at, + "created_at": tel.created_at, + } + items.append(TelemetryRead(**data)) return TelemetryList( - items=[TelemetryRead.model_validate(t) for t in records], + items=items, total=total, limit=limit, offset=offset, @@ -62,10 +89,26 @@ async def get_telemetry( telemetry_id: str, ) -> TelemetryRead: """Get a single telemetry record by ID.""" - query = select(Telemetry).where(Telemetry.id == telemetry_id) - telemetry = session.execute(query).scalar_one_or_none() + ReceiverNode = aliased(Node) + query = ( + select(Telemetry, ReceiverNode.public_key.label("receiver_pk")) + .outerjoin(ReceiverNode, Telemetry.receiver_node_id == ReceiverNode.id) + .where(Telemetry.id == telemetry_id) + ) + result = session.execute(query).one_or_none() - if not telemetry: + if not result: raise HTTPException(status_code=404, detail="Telemetry record not found") - return TelemetryRead.model_validate(telemetry) + tel, receiver_pk = result + data = { + "id": tel.id, + "receiver_node_id": tel.receiver_node_id, + "receiver_public_key": receiver_pk, + "node_id": tel.node_id, + "node_public_key": tel.node_public_key, + "parsed_data": tel.parsed_data, + "received_at": tel.received_at, + "created_at": tel.created_at, + } + return TelemetryRead(**data) diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py index 38a5b42..5cdba46 100644 --- a/src/meshcore_hub/api/routes/trace_paths.py +++ b/src/meshcore_hub/api/routes/trace_paths.py @@ -5,10 +5,11 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import func, select +from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import TracePath +from meshcore_hub.common.models import Node, TracePath from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead router = APIRouter() @@ -18,14 +19,25 @@ router = APIRouter() async def list_trace_paths( _: RequireRead, session: DbSession, + receiver_public_key: Optional[str] = Query( + None, description="Filter by receiver node public key" + ), 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"), offset: int = Query(0, ge=0, description="Page offset"), ) -> TracePathList: """List trace paths with filtering and pagination.""" - # Build query - query = select(TracePath) + # Alias for receiver node join + ReceiverNode = aliased(Node) + + # Build query with receiver node join + query = select(TracePath, ReceiverNode.public_key.label("receiver_pk")).outerjoin( + ReceiverNode, TracePath.receiver_node_id == ReceiverNode.id + ) + + if receiver_public_key: + query = query.where(ReceiverNode.public_key == receiver_public_key) if since: query = query.where(TracePath.received_at >= since) @@ -41,10 +53,29 @@ async def list_trace_paths( query = query.order_by(TracePath.received_at.desc()).offset(offset).limit(limit) # Execute - trace_paths = session.execute(query).scalars().all() + results = session.execute(query).all() + + # Build response with receiver_public_key + items = [] + for tp, receiver_pk in results: + data = { + "id": tp.id, + "receiver_node_id": tp.receiver_node_id, + "receiver_public_key": receiver_pk, + "initiator_tag": tp.initiator_tag, + "path_len": tp.path_len, + "flags": tp.flags, + "auth": tp.auth, + "path_hashes": tp.path_hashes, + "snr_values": tp.snr_values, + "hop_count": tp.hop_count, + "received_at": tp.received_at, + "created_at": tp.created_at, + } + items.append(TracePathRead(**data)) return TracePathList( - items=[TracePathRead.model_validate(t) for t in trace_paths], + items=items, total=total, limit=limit, offset=offset, @@ -58,10 +89,30 @@ async def get_trace_path( trace_path_id: str, ) -> TracePathRead: """Get a single trace path by ID.""" - query = select(TracePath).where(TracePath.id == trace_path_id) - trace_path = session.execute(query).scalar_one_or_none() + ReceiverNode = aliased(Node) + query = ( + select(TracePath, ReceiverNode.public_key.label("receiver_pk")) + .outerjoin(ReceiverNode, TracePath.receiver_node_id == ReceiverNode.id) + .where(TracePath.id == trace_path_id) + ) + result = session.execute(query).one_or_none() - if not trace_path: + if not result: raise HTTPException(status_code=404, detail="Trace path not found") - return TracePathRead.model_validate(trace_path) + tp, receiver_pk = result + data = { + "id": tp.id, + "receiver_node_id": tp.receiver_node_id, + "receiver_public_key": receiver_pk, + "initiator_tag": tp.initiator_tag, + "path_len": tp.path_len, + "flags": tp.flags, + "auth": tp.auth, + "path_hashes": tp.path_hashes, + "snr_values": tp.snr_values, + "hop_count": tp.hop_count, + "received_at": tp.received_at, + "created_at": tp.created_at, + } + return TracePathRead(**data) diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index d45af4d..f17ee31 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -13,10 +13,16 @@ class MessageRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) + receiver_public_key: Optional[str] = Field( + default=None, description="Receiving interface node public key" + ) message_type: str = Field(..., description="Message type (contact, channel)") pubkey_prefix: Optional[str] = Field( default=None, description="Sender's public key prefix (12 chars)" ) + sender_name: Optional[str] = Field( + default=None, description="Sender's advertised node name" + ) sender_friendly_name: Optional[str] = Field( default=None, description="Sender's friendly name from node tags" ) @@ -83,6 +89,9 @@ class AdvertisementRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) + receiver_public_key: Optional[str] = Field( + default=None, description="Receiving interface node public key" + ) node_id: Optional[str] = Field(default=None, description="Advertised node UUID") public_key: str = Field(..., description="Advertised public key") name: Optional[str] = Field(default=None, description="Advertised name") @@ -111,6 +120,9 @@ class TracePathRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) + receiver_public_key: Optional[str] = Field( + default=None, description="Receiving interface node public key" + ) initiator_tag: int = Field(..., description="Trace identifier") path_len: Optional[int] = Field(default=None, description="Path length") flags: Optional[int] = Field(default=None, description="Trace flags") @@ -145,6 +157,9 @@ class TelemetryRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) + receiver_public_key: Optional[str] = Field( + default=None, description="Receiving interface node public key" + ) node_id: Optional[str] = Field(default=None, description="Reporting node UUID") node_public_key: str = Field(..., description="Reporting node public key") parsed_data: Optional[dict] = Field( diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 2aa0fed..c30b4c3 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -57,6 +57,7 @@