mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-08 01:42:53 +02:00
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:
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user