Merge pull request #20 from ipnet-mesh/claude/ui-improvements-ads-page-01GQvLau46crtrqftWzFie5d

UI improvements and advertisements page
This commit is contained in:
JingleManSweep
2025-12-05 18:26:07 +00:00
committed by GitHub
13 changed files with 562 additions and 90 deletions

View File

@@ -5,7 +5,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
@@ -15,6 +15,16 @@ from meshcore_hub.common.schemas.messages import AdvertisementList, Advertisemen
router = APIRouter()
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
"""Extract friendly_name tag from a node's tags."""
if not node or not node.tags:
return None
for tag in node.tags:
if tag.key == "friendly_name":
return tag.value
return None
@router.get("", response_model=AdvertisementList)
async def list_advertisements(
_: RequireRead,
@@ -29,13 +39,24 @@ async def list_advertisements(
offset: int = Query(0, ge=0, description="Page offset"),
) -> AdvertisementList:
"""List advertisements with filtering and pagination."""
# Alias for receiver node join
# Aliases for node joins
ReceiverNode = aliased(Node)
SourceNode = 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)
# Build query with both receiver and source node joins
query = (
select(
Advertisement,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
SourceNode.name.label("source_name"),
SourceNode.id.label("source_id"),
SourceNode.adv_type.label("source_adv_type"),
)
.outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id)
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
)
if public_key:
query = query.where(Advertisement.public_key == public_key)
@@ -59,17 +80,39 @@ async def list_advertisements(
# Execute
results = session.execute(query).all()
# Build response with received_by
# Collect node IDs to fetch tags
node_ids = set()
for row in results:
if row.receiver_id:
node_ids.add(row.receiver_id)
if row.source_id:
node_ids.add(row.source_id)
# Fetch nodes with tags
nodes_by_id: dict[str, Node] = {}
if node_ids:
nodes_query = (
select(Node).where(Node.id.in_(node_ids)).options(selectinload(Node.tags))
)
nodes = session.execute(nodes_query).scalars().all()
nodes_by_id = {n.id: n for n in nodes}
# Build response with node details
items = []
for adv, receiver_pk in results:
for row in results:
adv = row[0]
receiver_node = nodes_by_id.get(row.receiver_id) if row.receiver_id else None
source_node = nodes_by_id.get(row.source_id) if row.source_id else None
data = {
"id": adv.id,
"receiver_node_id": adv.receiver_node_id,
"received_by": receiver_pk,
"node_id": adv.node_id,
"received_by": row.receiver_pk,
"receiver_name": row.receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"public_key": adv.public_key,
"name": adv.name,
"adv_type": adv.adv_type,
"node_name": row.source_name,
"node_friendly_name": _get_friendly_name(source_node),
"adv_type": adv.adv_type or row.source_adv_type,
"flags": adv.flags,
"received_at": adv.received_at,
"created_at": adv.created_at,
@@ -92,9 +135,19 @@ async def get_advertisement(
) -> AdvertisementRead:
"""Get a single advertisement by ID."""
ReceiverNode = aliased(Node)
SourceNode = aliased(Node)
query = (
select(Advertisement, ReceiverNode.public_key.label("receiver_pk"))
select(
Advertisement,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
SourceNode.name.label("source_name"),
SourceNode.id.label("source_id"),
SourceNode.adv_type.label("source_adv_type"),
)
.outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id)
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
.where(Advertisement.id == advertisement_id)
)
result = session.execute(query).one_or_none()
@@ -102,15 +155,35 @@ async def get_advertisement(
if not result:
raise HTTPException(status_code=404, detail="Advertisement not found")
adv, receiver_pk = result
adv = result[0]
# Fetch nodes with tags for friendly names
node_ids = []
if result.receiver_id:
node_ids.append(result.receiver_id)
if result.source_id:
node_ids.append(result.source_id)
nodes_by_id: dict[str, Node] = {}
if node_ids:
nodes_query = (
select(Node).where(Node.id.in_(node_ids)).options(selectinload(Node.tags))
)
nodes = session.execute(nodes_query).scalars().all()
nodes_by_id = {n.id: n for n in nodes}
receiver_node = nodes_by_id.get(result.receiver_id) if result.receiver_id else None
source_node = nodes_by_id.get(result.source_id) if result.source_id else None
data = {
"id": adv.id,
"receiver_node_id": adv.receiver_node_id,
"received_by": receiver_pk,
"node_id": adv.node_id,
"received_by": result.receiver_pk,
"receiver_name": result.receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"public_key": adv.public_key,
"name": adv.name,
"adv_type": adv.adv_type,
"node_name": result.source_name,
"node_friendly_name": _get_friendly_name(source_node),
"adv_type": adv.adv_type or result.source_adv_type,
"flags": adv.flags,
"received_at": adv.received_at,
"created_at": adv.created_at,

View File

@@ -9,7 +9,11 @@ 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, NodeTag
from meshcore_hub.common.schemas.messages import DashboardStats, RecentAdvertisement
from meshcore_hub.common.schemas.messages import (
ChannelMessage,
DashboardStats,
RecentAdvertisement,
)
router = APIRouter()
@@ -123,6 +127,55 @@ async def get_stats(
int(channel): int(count) for channel, count in channel_results
}
# Get latest 5 messages for each channel that has messages
channel_messages: dict[int, list[ChannelMessage]] = {}
for channel_idx, _ in channel_results:
messages_query = (
select(Message)
.where(Message.message_type == "channel")
.where(Message.channel_idx == channel_idx)
.order_by(Message.received_at.desc())
.limit(5)
)
channel_msgs = session.execute(messages_query).scalars().all()
# Look up sender names for these messages
msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix]
msg_sender_names: dict[str, str] = {}
msg_friendly_names: dict[str, str] = {}
if msg_prefixes:
for prefix in set(msg_prefixes):
sender_node_query = select(Node.public_key, Node.name).where(
Node.public_key.startswith(prefix)
)
for public_key, name in session.execute(sender_node_query).all():
if name:
msg_sender_names[public_key[:12]] = name
sender_friendly_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(sender_friendly_query).all():
msg_friendly_names[public_key[:12]] = value
channel_messages[int(channel_idx)] = [
ChannelMessage(
text=m.text,
sender_name=(
msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
sender_friendly_name=(
msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
pubkey_prefix=m.pubkey_prefix,
received_at=m.received_at,
)
for m in channel_msgs
]
return DashboardStats(
total_nodes=total_nodes,
active_nodes=active_nodes,
@@ -132,6 +185,7 @@ async def get_stats(
advertisements_24h=advertisements_24h,
recent_advertisements=recent_advertisements,
channel_message_counts=channel_message_counts,
channel_messages=channel_messages,
)

View File

@@ -5,7 +5,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
@@ -15,6 +15,16 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead
router = APIRouter()
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
"""Extract friendly_name tag from a node's tags."""
if not node or not node.tags:
return None
for tag in node.tags:
if tag.key == "friendly_name":
return tag.value
return None
@router.get("", response_model=MessageList)
async def list_messages(
_: RequireRead,
@@ -36,9 +46,12 @@ async def list_messages(
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
)
query = select(
Message,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
).outerjoin(ReceiverNode, Message.receiver_node_id == ReceiverNode.id)
if message_type:
query = query.where(Message.message_type == message_type)
@@ -96,13 +109,39 @@ async def list_messages(
for public_key, value in session.execute(friendly_name_query).all():
friendly_names[public_key[:12]] = value
# Collect receiver node IDs to fetch tags
receiver_ids = set()
for row in results:
if row.receiver_id:
receiver_ids.add(row.receiver_id)
# Fetch receiver nodes with tags
receivers_by_id: dict[str, Node] = {}
if receiver_ids:
receivers_query = (
select(Node)
.where(Node.id.in_(receiver_ids))
.options(selectinload(Node.tags))
)
receivers = session.execute(receivers_query).scalars().all()
receivers_by_id = {n.id: n for n in receivers}
# Build response with sender info and received_by
items = []
for m, receiver_pk in results:
for row in results:
m = row[0]
receiver_pk = row.receiver_pk
receiver_name = row.receiver_name
receiver_node = (
receivers_by_id.get(row.receiver_id) if row.receiver_id else None
)
msg_dict = {
"id": m.id,
"receiver_node_id": m.receiver_node_id,
"received_by": receiver_pk,
"receiver_name": receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"message_type": m.message_type,
"pubkey_prefix": m.pubkey_prefix,
"sender_name": (

View File

@@ -12,6 +12,10 @@ class MessageRead(BaseModel):
received_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_friendly_name: Optional[str] = Field(
default=None, description="Receiver friendly name from tags"
)
message_type: str = Field(..., description="Message type (contact, channel)")
pubkey_prefix: Optional[str] = Field(
default=None, description="Sender's public key prefix (12 chars)"
@@ -84,8 +88,18 @@ class AdvertisementRead(BaseModel):
received_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_friendly_name: Optional[str] = Field(
default=None, description="Receiver friendly name from tags"
)
public_key: str = Field(..., description="Advertised public key")
name: Optional[str] = Field(default=None, description="Advertised name")
node_name: Optional[str] = Field(
default=None, description="Node name from nodes table"
)
node_friendly_name: Optional[str] = Field(
default=None, description="Node friendly name from tags"
)
adv_type: Optional[str] = Field(default=None, description="Node type")
flags: Optional[int] = Field(default=None, description="Capability flags")
received_at: datetime = Field(..., description="When received")
@@ -173,6 +187,20 @@ class RecentAdvertisement(BaseModel):
received_at: datetime = Field(..., description="When received")
class ChannelMessage(BaseModel):
"""Schema for a channel message summary."""
text: str = Field(..., description="Message text")
sender_name: Optional[str] = Field(default=None, description="Sender name")
sender_friendly_name: Optional[str] = Field(
default=None, description="Sender friendly name"
)
pubkey_prefix: Optional[str] = Field(
default=None, description="Sender public key prefix"
)
received_at: datetime = Field(..., description="When received")
class DashboardStats(BaseModel):
"""Schema for dashboard statistics."""
@@ -191,3 +219,7 @@ class DashboardStats(BaseModel):
default_factory=dict,
description="Message count per channel",
)
channel_messages: dict[int, list[ChannelMessage]] = Field(
default_factory=dict,
description="Recent messages per channel (up to 5 each)",
)

View File

@@ -6,6 +6,7 @@ from meshcore_hub.web.routes.home import router as home_router
from meshcore_hub.web.routes.network import router as network_router
from meshcore_hub.web.routes.nodes import router as nodes_router
from meshcore_hub.web.routes.messages import router as messages_router
from meshcore_hub.web.routes.advertisements import router as advertisements_router
from meshcore_hub.web.routes.map import router as map_router
from meshcore_hub.web.routes.members import router as members_router
@@ -17,6 +18,7 @@ web_router.include_router(home_router)
web_router.include_router(network_router)
web_router.include_router(nodes_router)
web_router.include_router(messages_router)
web_router.include_router(advertisements_router)
web_router.include_router(map_router)
web_router.include_router(members_router)

View File

@@ -0,0 +1,64 @@
"""Advertisements page route."""
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/advertisements", response_class=HTMLResponse)
async def advertisements_list(
request: Request,
public_key: str | None = Query(None, description="Filter by public key"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
"""Render the advertisements list page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
# Calculate offset
offset = (page - 1) * limit
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if public_key:
params["public_key"] = public_key
# Fetch advertisements from API
advertisements = []
total = 0
try:
response = await request.app.state.http_client.get(
"/api/v1/advertisements", params=params
)
if response.status_code == 200:
data = response.json()
advertisements = data.get("items", [])
total = data.get("total", 0)
except Exception as e:
logger.warning(f"Failed to fetch advertisements from API: {e}")
context["api_error"] = str(e)
# Calculate pagination
total_pages = (total + limit - 1) // limit if total > 0 else 1
context.update(
{
"advertisements": advertisements,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
"public_key": public_key or "",
}
)
return templates.TemplateResponse("advertisements.html", context)

View File

@@ -1,10 +1,13 @@
"""Home page route."""
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from meshcore_hub.web.app import get_network_context, get_templates
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -15,4 +18,24 @@ async def home(request: Request) -> HTMLResponse:
context = get_network_context(request)
context["request"] = request
# Fetch stats from API
stats = {
"total_nodes": 0,
"active_nodes": 0,
"total_messages": 0,
"messages_today": 0,
"total_advertisements": 0,
"advertisements_24h": 0,
}
try:
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
if response.status_code == 200:
stats = response.json()
except Exception as e:
logger.warning(f"Failed to fetch stats from API: {e}")
context["api_error"] = str(e)
context["stats"] = stats
return templates.TemplateResponse("home.html", context)

View File

@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Advertisements{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Advertisements</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Public Key</span>
</label>
<input type="text" name="public_key" value="{{ public_key }}" placeholder="Filter by public key..." class="input input-bordered input-sm w-80" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
</div>
<!-- Advertisements Table -->
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th>Received By</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for ad in advertisements %}
<tr class="hover">
<td>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
{% if ad.node_friendly_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_friendly_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
{% endif %}
</a>
</td>
<td>
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif ad.adv_type %}
<span title="{{ ad.adv_type }}">📍</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td>
{% if ad.received_by %}
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
{% if ad.receiver_friendly_name or ad.receiver_name %}
<div class="font-medium">{{ ad.receiver_friendly_name or ad.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-sm whitespace-nowrap">
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-8 opacity-70">No advertisements found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -58,6 +58,7 @@
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">Advertisements</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
@@ -75,6 +76,7 @@
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">Advertisements</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
@@ -112,7 +114,7 @@
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">Powered by MeshCore Hub v{{ version }}</p>
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> v{{ version }}</p>
</aside>
</footer>

View File

@@ -3,17 +3,17 @@
{% block title %}{{ network_name }} - Home{% endblock %}
{% block content %}
<div class="hero min-h-[50vh] bg-base-100 rounded-box">
<div class="hero py-8 bg-base-100 rounded-box">
<div class="hero-content text-center">
<div class="max-w-2xl">
<h1 class="text-5xl font-bold">{{ network_name }}</h1>
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
{% if network_city and network_country %}
<p class="py-2 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
{% endif %}
{% if network_welcome_text %}
<p class="py-6">{{ network_welcome_text }}</p>
<p class="py-4">{{ network_welcome_text }}</p>
{% else %}
<p class="py-6">
<p class="py-4">
Welcome to the {{ network_name }} mesh network dashboard.
Monitor network activity, view connected nodes, and explore message history.
</p>
@@ -23,26 +23,83 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
View Network Stats
Dashboard
</a>
<a href="/nodes" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Browse Nodes
Nodes
</a>
<a href="/map" class="btn btn-accent">
<a href="/advertisements" class="btn btn-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
<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>
View Map
Advertisements
</a>
<a href="/messages" class="btn btn-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Messages
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- 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="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">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 -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title">Total Messages</div>
<div class="stat-value text-accent">{{ stats.total_messages }}</div>
<div class="stat-desc">All time</div>
</div>
<!-- Messages Today -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-info">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title">Messages Today</div>
<div class="stat-value text-info">{{ stats.messages_today }}</div>
<div class="stat-desc">Last 24 hours</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<!-- Network Info Card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">

View File

@@ -53,21 +53,17 @@
<table class="table table-zebra">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Time</th>
<th>From/Channel</th>
<th>Message</th>
<th>Receiver</th>
<th>SNR</th>
<th>Hops</th>
</tr>
</thead>
<tbody>
{% for msg in messages %}
<tr class="hover">
<td class="text-xs whitespace-nowrap">
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
</td>
<tr class="hover align-top">
<td>
{% if msg.message_type == 'channel' %}
<span class="badge badge-info badge-sm">Channel</span>
@@ -75,7 +71,10 @@
<span class="badge badge-success badge-sm">Direct</span>
{% endif %}
</td>
<td class="text-sm">
<td class="text-sm whitespace-nowrap">
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
</td>
<td class="text-sm whitespace-nowrap">
{% if msg.message_type == 'channel' %}
<span class="font-mono">CH{{ msg.channel_idx }}</span>
{% else %}
@@ -86,34 +85,32 @@
{% endif %}
{% endif %}
</td>
<td class="truncate-cell" title="{{ msg.text }}">
{{ msg.text or '-' }}
</td>
<td class="text-xs">
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
<td>
{% if msg.received_by %}
<span class="font-mono" title="{{ msg.received_by }}">{{ msg.received_by[:8] }}...</span>
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
{% if msg.receiver_friendly_name or msg.receiver_name %}
<div class="font-medium">{{ msg.receiver_friendly_name or msg.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-center">
<td class="text-center whitespace-nowrap">
{% if msg.snr is not none %}
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
{% else %}
-
{% endif %}
</td>
<td class="text-center">
{% if msg.hops is not none %}
<span class="badge badge-ghost badge-sm">{{ msg.hops }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center py-8 opacity-70">No messages found.</td>
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -130,39 +130,37 @@
</div>
</div>
<!-- Channel Stats -->
<!-- Channel Messages -->
{% if stats.channel_messages %}
<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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
Channel Messages
Recent Channel Messages
</h2>
{% if stats.channel_message_counts %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Channel</th>
<th class="text-right">Count</th>
</tr>
</thead>
<tbody>
{% for channel, count in stats.channel_message_counts.items() %}
<tr>
<td>Channel {{ channel }}</td>
<td class="text-right font-mono">{{ count }}</td>
</tr>
<div class="space-y-4">
{% for channel, messages in stats.channel_messages.items() %}
<div>
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span class="badge badge-info badge-sm">CH{{ channel }}</span>
Channel {{ channel }}
</h3>
<div class="space-y-1 pl-2 border-l-2 border-base-300">
{% for msg in messages %}
<div class="text-sm">
<span class="text-xs opacity-50">{{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }}</span>
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
</div>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm opacity-70">No channel messages recorded yet.</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- Quick Actions -->

View File

@@ -49,9 +49,8 @@
<table class="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>Node</th>
<th>Type</th>
<th>Public Key</th>
<th>Last Seen</th>
<th>Tags</th>
</tr>
@@ -65,8 +64,15 @@
{% endif %}
{% endfor %}
<tr class="hover">
<td class="font-medium">
<a href="/nodes/{{ node.public_key }}" class="link link-hover">{{ ns.friendly_name or node.name or '-' }}</a>
<td>
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
{% if ns.friendly_name or node.name %}
<div class="font-medium">{{ ns.friendly_name or node.name }}</div>
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
{% endif %}
</a>
</td>
<td>
{% if node.adv_type and node.adv_type|lower == 'chat' %}
@@ -81,10 +87,7 @@
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="font-mono text-xs truncate-cell" title="{{ node.public_key }}">
{{ node.public_key[:16] }}...
</td>
<td class="text-sm">
<td class="text-sm whitespace-nowrap">
{% if node.last_seen %}
{{ node.last_seen[:19].replace('T', ' ') }}
{% else %}
@@ -108,7 +111,7 @@
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-8 opacity-70">No nodes found.</td>
<td colspan="4" class="text-center py-8 opacity-70">No nodes found.</td>
</tr>
{% endfor %}
</tbody>