Merge pull request #15 from ipnet-mesh/feature/originator-address

Updates
This commit is contained in:
JingleManSweep
2025-12-04 15:52:49 +00:00
committed by GitHub
9 changed files with 284 additions and 45 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -57,6 +57,7 @@
<th>Type</th>
<th>From/Channel</th>
<th>Message</th>
<th>Receiver</th>
<th>SNR</th>
<th>Hops</th>
</tr>
@@ -78,8 +79,8 @@
{% if msg.message_type == 'channel' %}
<span class="font-mono">CH{{ msg.channel_idx }}</span>
{% else %}
{% if msg.sender_friendly_name %}
<span class="font-medium">{{ msg.sender_friendly_name }}</span>
{% if msg.sender_friendly_name or msg.sender_name %}
<span class="font-medium">{{ msg.sender_friendly_name or msg.sender_name }}</span>
{% else %}
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
{% endif %}
@@ -88,6 +89,13 @@
<td class="truncate-cell" title="{{ msg.text }}">
{{ msg.text or '-' }}
</td>
<td class="text-xs">
{% if msg.receiver_public_key %}
<span class="font-mono" title="{{ msg.receiver_public_key }}">{{ msg.receiver_public_key[:8] }}...</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-center">
{% if msg.snr is not none %}
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
@@ -105,7 +113,7 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
<td colspan="7" class="text-center py-8 opacity-70">No messages found.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -97,7 +97,9 @@
{% for ad in stats.recent_advertisements %}
<tr>
<td>
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
</a>
{% if ad.friendly_name or ad.name %}
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
{% endif %}

View File

@@ -103,6 +103,7 @@
<th>Time</th>
<th>Type</th>
<th>Name</th>
<th>Receiver</th>
</tr>
</thead>
<tbody>
@@ -111,6 +112,13 @@
<td class="text-xs">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
<td>{{ adv.adv_type or '-' }}</td>
<td>{{ adv.name or '-' }}</td>
<td class="text-xs">
{% if adv.receiver_public_key %}
<span class="font-mono" title="{{ adv.receiver_public_key }}">{{ adv.receiver_public_key[:8] }}...</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -133,6 +141,7 @@
<tr>
<th>Time</th>
<th>Data</th>
<th>Receiver</th>
</tr>
</thead>
<tbody>
@@ -146,6 +155,13 @@
-
{% endif %}
</td>
<td class="text-xs">
{% if tel.receiver_public_key %}
<span class="font-mono" title="{{ tel.receiver_public_key }}">{{ tel.receiver_public_key[:8] }}...</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>