diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 31f35f9..3d75a9e 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -190,6 +190,8 @@ services: - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - DATA_HOME=/data + # Explicitly unset to use DATA_HOME-based default path + - DATABASE_URL= # Webhook configuration - WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-} - WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-} @@ -241,6 +243,8 @@ services: - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - DATA_HOME=/data + # Explicitly unset to use DATA_HOME-based default path + - DATABASE_URL= - API_HOST=0.0.0.0 - API_PORT=8000 - API_READ_KEY=${API_READ_KEY:-} @@ -311,6 +315,8 @@ services: - ${DATA_HOME:-./data}:/data environment: - DATA_HOME=/data + # Explicitly unset to use DATA_HOME-based default path + - DATABASE_URL= command: ["db", "upgrade"] # ========================================================================== @@ -329,6 +335,8 @@ services: environment: - DATA_HOME=/data - LOG_LEVEL=${LOG_LEVEL:-INFO} + # Explicitly unset to use DATA_HOME-based default path + - DATABASE_URL= # Uses default tags file: /data/collector/tags.json command: ["collector", "import-tags"] diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 556ed88..80a44d9 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -8,8 +8,8 @@ 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 -from meshcore_hub.common.schemas.messages import DashboardStats +from meshcore_hub.common.models import Advertisement, Message, Node, NodeTag +from meshcore_hub.common.schemas.messages import DashboardStats, RecentAdvertisement router = APIRouter() @@ -55,6 +55,49 @@ async def get_stats( session.execute(select(func.count()).select_from(Advertisement)).scalar() or 0 ) + # Advertisements in last 24h + advertisements_24h = ( + session.execute( + select(func.count()) + .select_from(Advertisement) + .where(Advertisement.received_at >= yesterday) + ).scalar() + or 0 + ) + + # Recent advertisements (last 10) + recent_ads = ( + session.execute( + select(Advertisement).order_by(Advertisement.received_at.desc()).limit(10) + ) + .scalars() + .all() + ) + + # Get friendly_name tags for the advertised nodes + ad_public_keys = [ad.public_key for ad in recent_ads] + friendly_names: dict[str, str] = {} + if ad_public_keys: + friendly_name_query = ( + select(Node.public_key, NodeTag.value) + .join(NodeTag, Node.id == NodeTag.node_id) + .where(Node.public_key.in_(ad_public_keys)) + .where(NodeTag.key == "friendly_name") + ) + for public_key, value in session.execute(friendly_name_query).all(): + friendly_names[public_key] = value + + recent_advertisements = [ + RecentAdvertisement( + public_key=ad.public_key, + name=ad.name, + friendly_name=friendly_names.get(ad.public_key), + adv_type=ad.adv_type, + received_at=ad.received_at, + ) + for ad in recent_ads + ] + # Channel message counts channel_counts_query = ( select(Message.channel_idx, func.count()) @@ -73,6 +116,8 @@ async def get_stats( total_messages=total_messages, messages_today=messages_today, total_advertisements=total_advertisements, + advertisements_24h=advertisements_24h, + recent_advertisements=recent_advertisements, channel_message_counts=channel_message_counts, ) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 4c57731..e8b7d2f 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -8,7 +8,7 @@ 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 Message +from meshcore_hub.common.models import Message, Node, NodeTag from meshcore_hub.common.schemas.messages import MessageList, MessageRead router = APIRouter() @@ -59,8 +59,47 @@ async def list_messages( # Execute messages = session.execute(query).scalars().all() + # Look up friendly_names for senders with pubkey_prefix + pubkey_prefixes = [m.pubkey_prefix for m in messages if m.pubkey_prefix] + 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): + friendly_name_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(friendly_name_query).all(): + # Map the prefix to the friendly_name + friendly_names[public_key[:12]] = value + + # Build response with friendly_names + items = [] + for m in messages: + msg_dict = { + "id": m.id, + "receiver_node_id": m.receiver_node_id, + "message_type": m.message_type, + "pubkey_prefix": m.pubkey_prefix, + "sender_friendly_name": ( + friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None + ), + "channel_idx": m.channel_idx, + "text": m.text, + "path_len": m.path_len, + "txt_type": m.txt_type, + "signature": m.signature, + "snr": m.snr, + "sender_timestamp": m.sender_timestamp, + "received_at": m.received_at, + "created_at": m.created_at, + } + items.append(MessageRead(**msg_dict)) + return MessageList( - items=[MessageRead.model_validate(m) for m in messages], + items=items, total=total, limit=limit, offset=offset, diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index 97951d5..8e8a55b 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -209,7 +209,7 @@ def run_cmd(ctx: click.Context) -> None: @collector.command("import-tags") -@click.argument("file", type=click.Path(exists=True), required=False, default=None) +@click.argument("file", type=click.Path(), required=False, default=None) @click.option( "--no-create-nodes", is_flag=True, @@ -253,10 +253,11 @@ def import_tags_cmd( settings = ctx.obj["settings"] tags_file = file if file else settings.effective_tags_file - # Check if file exists when using default - if not file and not Path(tags_file).exists(): + # Check if file exists + if not Path(tags_file).exists(): click.echo(f"Tags file not found: {tags_file}") - click.echo("Specify a file path or create the default tags file.") + if not file: + click.echo("Specify a file path or create the default tags file.") return click.echo(f"Importing tags from: {tags_file}") diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index d5329cf..d45af4d 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -17,6 +17,9 @@ class MessageRead(BaseModel): pubkey_prefix: Optional[str] = Field( default=None, description="Sender's public key prefix (12 chars)" ) + sender_friendly_name: Optional[str] = Field( + default=None, description="Sender's friendly name from node tags" + ) channel_idx: Optional[int] = Field(default=None, description="Channel index") text: str = Field(..., description="Message content") path_len: Optional[int] = Field(default=None, description="Number of hops") @@ -163,6 +166,16 @@ class TelemetryList(BaseModel): offset: int = Field(..., description="Page offset") +class RecentAdvertisement(BaseModel): + """Schema for a recent advertisement summary.""" + + public_key: str = Field(..., description="Node public key") + name: Optional[str] = Field(default=None, description="Node name") + friendly_name: Optional[str] = Field(default=None, description="Friendly name tag") + adv_type: Optional[str] = Field(default=None, description="Node type") + received_at: datetime = Field(..., description="When received") + + class DashboardStats(BaseModel): """Schema for dashboard statistics.""" @@ -171,6 +184,12 @@ class DashboardStats(BaseModel): total_messages: int = Field(..., description="Total number of messages") messages_today: int = Field(..., description="Messages received today") total_advertisements: int = Field(..., description="Total advertisements") + advertisements_24h: int = Field( + default=0, description="Advertisements received in last 24h" + ) + recent_advertisements: list[RecentAdvertisement] = Field( + default_factory=list, description="Last 10 advertisements" + ) channel_message_counts: dict[int, int] = Field( default_factory=dict, description="Message count per channel", diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py index dbccb32..c5f982e 100644 --- a/src/meshcore_hub/web/routes/map.py +++ b/src/meshcore_hub/web/routes/map.py @@ -40,23 +40,33 @@ async def map_data(request: Request) -> JSONResponse: tags = node.get("tags", []) lat = None lon = None + friendly_name = None for tag in tags: - if tag.get("key") == "lat": + key = tag.get("key") + if key == "lat": try: lat = float(tag.get("value")) except (ValueError, TypeError): pass - elif tag.get("key") == "lon": + elif key == "lon": try: lon = float(tag.get("value")) except (ValueError, TypeError): pass + elif key == "friendly_name": + friendly_name = tag.get("value") if lat is not None and lon is not None: + # Use friendly_name, then node name, then public key prefix + display_name = ( + friendly_name + or node.get("name") + or node.get("public_key", "")[:12] + ) nodes_with_location.append( { "public_key": node.get("public_key"), - "name": node.get("name") or node.get("public_key", "")[:12], + "name": display_name, "adv_type": node.get("adv_type"), "lat": lat, "lon": lon, diff --git a/src/meshcore_hub/web/routes/network.py b/src/meshcore_hub/web/routes/network.py index 74f1c18..3c6ccb4 100644 --- a/src/meshcore_hub/web/routes/network.py +++ b/src/meshcore_hub/web/routes/network.py @@ -25,6 +25,8 @@ async def network_overview(request: Request) -> HTMLResponse: "total_messages": 0, "messages_today": 0, "total_advertisements": 0, + "advertisements_24h": 0, + "recent_advertisements": [], "channel_message_counts": {}, } diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html index 93f478b..2aa0fed 100644 --- a/src/meshcore_hub/web/templates/messages.html +++ b/src/meshcore_hub/web/templates/messages.html @@ -74,11 +74,15 @@ Direct {% endif %} -
Total advertisements received
+ {% if stats.recent_advertisements %} +| Node | +Type | +Received | +
|---|---|---|
|
+ {{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}
+ {% if ad.friendly_name or ad.name %}
+ {{ ad.public_key[:12] }}...
+ {% endif %}
+ |
+ {{ ad.adv_type or '-' }} | +{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }} | +
No advertisements recorded yet.
+ {% endif %}