Add friendly_name tag display across web UI and fix CLI issues

- Display friendly_name tags for nodes throughout web UI:
  - nodes.html: Show friendly_name in node list table
  - node_detail.html: Show in breadcrumb and page title
  - network.html: Show in recent advertisements (24h stats)
  - messages.html: Show sender friendly_name for direct messages
  - map.py: Include friendly_name in map popup data

- API changes:
  - dashboard.py: Look up friendly_name tags for recent advertisements
  - messages.py: Look up sender friendly_name by pubkey_prefix

- Fix collector CLI import-tags command:
  - Remove click.Path(exists=True) to allow optional file argument
  - Add manual file existence check in function

- Add DATABASE_URL= to docker-compose.yml.example to prevent
  host environment variable from overriding computed defaults

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Louis King
2025-12-03 22:16:18 +00:00
parent 264dafbca5
commit 6f26f011ea
11 changed files with 203 additions and 26 deletions
+8
View File
@@ -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"]
+47 -2
View File
@@ -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,
)
+41 -2
View File
@@ -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,
+5 -4
View File
@@ -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}")
@@ -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",
+13 -3
View File
@@ -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,
+2
View File
@@ -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": {},
}
+7 -3
View File
@@ -74,11 +74,15 @@
<span class="badge badge-success badge-sm">Direct</span>
{% endif %}
</td>
<td class="font-mono text-xs">
<td class="text-sm">
{% if msg.message_type == 'channel' %}
CH{{ msg.channel_idx }}
<span class="font-mono">CH{{ msg.channel_idx }}</span>
{% else %}
{{ (msg.pubkey_prefix or '-')[:12] }}
{% if msg.sender_friendly_name %}
<span class="font-medium">{{ msg.sender_friendly_name }}</span>
{% else %}
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
{% endif %}
{% endif %}
</td>
<td class="truncate-cell" title="{{ msg.text }}">
+36 -9
View File
@@ -36,16 +36,16 @@
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Active Nodes -->
<!-- Advertisements (24h) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
</div>
<div class="stat-title">Active Nodes</div>
<div class="stat-value text-secondary">{{ stats.active_nodes }}</div>
<div class="stat-desc">Active in last 24 hours</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_24h }}</div>
<div class="stat-desc">Received in last 24 hours</div>
</div>
<!-- Total Messages -->
@@ -75,17 +75,44 @@
<!-- Additional Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Advertisements -->
<!-- Recent Advertisements -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Advertisements
Recent Advertisements
</h2>
<div class="stat-value">{{ stats.total_advertisements }}</div>
<p class="text-sm opacity-70">Total advertisements received</p>
{% if stats.recent_advertisements %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th class="text-right">Received</th>
</tr>
</thead>
<tbody>
{% for ad in stats.recent_advertisements %}
<tr>
<td>
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
{% if ad.friendly_name or ad.name %}
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
{% endif %}
</td>
<td>{{ ad.adv_type or '-' }}</td>
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70">No advertisements recorded yet.</p>
{% endif %}
</div>
</div>
@@ -7,7 +7,17 @@
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
<li>{{ node.name or public_key[:12] + '...' if node else 'Not Found' }}</li>
{% if node %}
{% set ns = namespace(friendly_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% endif %}
{% endfor %}
<li>{{ ns.friendly_name or node.name or public_key[:12] + '...' }}</li>
{% else %}
<li>Not Found</li>
{% endif %}
</ul>
</div>
@@ -21,11 +31,17 @@
{% endif %}
{% if node %}
{% set ns = namespace(friendly_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% endif %}
{% endfor %}
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{{ node.name or 'Unnamed Node' }}
{{ ns.friendly_name or node.name or 'Unnamed Node' }}
{% if node.adv_type %}
<span class="badge badge-secondary">{{ node.adv_type }}</span>
{% endif %}
+7 -1
View File
@@ -59,8 +59,14 @@
</thead>
<tbody>
{% for node in nodes %}
{% set ns = namespace(friendly_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% endif %}
{% endfor %}
<tr class="hover">
<td class="font-medium">{{ node.name or '-' }}</td>
<td class="font-medium">{{ ns.friendly_name or node.name or '-' }}</td>
<td class="font-mono text-xs truncate-cell" title="{{ node.public_key }}">
{{ node.public_key[:16] }}...
</td>