Compare commits

...

4 Commits

Author SHA1 Message Date
JingleManSweep
e4087efbf0 Merge pull request #61 from ipnet-mesh/feature/ui-improvements
Remove SNR column from messages and add last seen to members
2026-01-08 21:25:03 +00:00
Louis King
3051984fb9 Remove SNR column from messages and add last seen to members
- Remove SNR column from messages list (no longer provided by meshcore library)
- Add relative "last seen" time to nodes on members page with tooltip
- Add populateRelativeTimeElements() utility for time elements
2026-01-08 21:23:14 +00:00
JingleManSweep
eea2c90ea4 Merge pull request #58 from ipnet-mesh/feature/ui-improvements
Add member/node filters, mobile card views, and pagination macro
2026-01-08 20:15:54 +00:00
Louis King
d52c23fc29 Add member/node filters, mobile card views, and pagination macro
- Add member_id filter to nodes and advertisements API endpoints
- Add member and node dropdowns to web list pages
- Implement responsive mobile card view for nodes and advertisements
- Extract pagination into reusable Jinja2 macro (_macros.html)
- Fix Python version in README (3.11+ -> 3.13+)
2026-01-08 20:13:49 +00:00
11 changed files with 296 additions and 108 deletions

View File

@@ -1,6 +1,6 @@
# MeshCore Hub
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)

View File

@@ -96,6 +96,9 @@ async def list_advertisements(
received_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
member_id: Optional[str] = Query(
None, description="Filter by member_id tag value of source node"
),
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"),
@@ -143,6 +146,16 @@ async def list_advertisements(
if received_by:
query = query.where(ReceiverNode.public_key == received_by)
if member_id:
# Filter advertisements from nodes that have a member_id tag with the specified value
query = query.where(
SourceNode.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "member_id", NodeTag.value == member_id
)
)
)
if since:
query = query.where(Advertisement.received_at >= since)

View File

@@ -22,6 +22,7 @@ async def list_nodes(
None, description="Search in name tag, node name, or public key"
),
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
@@ -48,6 +49,16 @@ async def list_nodes(
if adv_type:
query = query.where(Node.adv_type == adv_type)
if member_id:
# Filter nodes that have a member_id tag with the specified value
query = query.where(
Node.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "member_id", NodeTag.value == member_id
)
)
)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0

View File

@@ -15,6 +15,8 @@ router = APIRouter()
async def advertisements_list(
request: Request,
search: str | None = Query(None, description="Search term"),
member_id: str | None = Query(None, description="Filter by member"),
public_key: str | None = Query(None, description="Filter by node public key"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
@@ -30,12 +32,41 @@ async def advertisements_list(
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if search:
params["search"] = search
if member_id:
params["member_id"] = member_id
if public_key:
params["public_key"] = public_key
# Fetch advertisements from API
advertisements = []
total = 0
members = []
nodes = []
try:
# Fetch members for dropdown
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if members_response.status_code == 200:
members = members_response.json().get("items", [])
# Fetch nodes for dropdown
nodes_response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
)
if nodes_response.status_code == 200:
nodes = nodes_response.json().get("items", [])
# Sort nodes alphabetically by display name
def get_node_display_name(node: dict) -> str:
for tag in node.get("tags") or []:
if tag.get("key") == "name":
return str(tag.get("value", "")).lower()
return str(node.get("name") or node.get("public_key", "")).lower()
nodes.sort(key=get_node_display_name)
response = await request.app.state.http_client.get(
"/api/v1/advertisements", params=params
)
@@ -58,6 +89,10 @@ async def advertisements_list(
"limit": limit,
"total_pages": total_pages,
"search": search or "",
"member_id": member_id or "",
"public_key": public_key or "",
"members": members,
"nodes": nodes,
}
)

View File

@@ -16,6 +16,7 @@ async def nodes_list(
request: Request,
search: str | None = Query(None, description="Search term"),
adv_type: str | None = Query(None, description="Filter by node type"),
member_id: str | None = Query(None, description="Filter by member"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
@@ -33,12 +34,22 @@ async def nodes_list(
params["search"] = search
if adv_type:
params["adv_type"] = adv_type
if member_id:
params["member_id"] = member_id
# Fetch nodes from API
nodes = []
total = 0
members = []
try:
# Fetch members for dropdown
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if members_response.status_code == 200:
members = members_response.json().get("items", [])
response = await request.app.state.http_client.get(
"/api/v1/nodes", params=params
)
@@ -62,6 +73,8 @@ async def nodes_list(
"total_pages": total_pages,
"search": search or "",
"adv_type": adv_type or "",
"member_id": member_id or "",
"members": members,
}
)

View File

@@ -56,8 +56,23 @@ function populateReceiverTooltips() {
});
}
/**
* Populate <time> elements with data-relative-time attribute
* Uses the datetime attribute as the timestamp source
*/
function populateRelativeTimeElements() {
document.querySelectorAll('time[data-relative-time]').forEach(el => {
const timestamp = el.getAttribute('datetime');
if (timestamp) {
const relTime = formatRelativeTime(timestamp);
el.textContent = relTime ? `${relTime} ago` : '';
}
});
}
// Auto-populate when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
populateRelativeTimestamps();
populateReceiverTooltips();
populateRelativeTimeElements();
});

View File

@@ -0,0 +1,47 @@
{# Reusable macros for templates #}
{#
Pagination macro
Parameters:
- page: Current page number
- total_pages: Total number of pages
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
#}
{% macro pagination(page, total_pages, params={}) %}
{% if total_pages > 1 %}
{% set query_parts = [] %}
{% for key, value in params.items() %}
{% if value is not none and value != '' %}
{% set _ = query_parts.append(key ~ '=' ~ value) %}
{% endif %}
{% endfor %}
{% set base_query = query_parts|join('&') %}
{% set query_prefix = '&' if base_query else '' %}
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" 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 }}{{ query_prefix }}{{ base_query }}" 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 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
</div>
</div>
{% endif %}
{% endmacro %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Advertisements{% endblock %}
@@ -27,14 +28,87 @@
</label>
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Search</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
{% if nodes %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node</span>
</label>
<select name="public_key" class="select select-bordered select-sm">
<option value="">All Nodes</option>
{% for node in nodes %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if members %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
</label>
<select name="member_id" class="select select-bordered select-sm">
<option value="">All Members</option>
{% for member in members %}
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Advertisements Table -->
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<!-- Advertisements List - Mobile Card View -->
<div class="lg:hidden space-y-3">
{% for ad in advertisements %}
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div class="min-w-0">
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
{% else %}
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
</div>
{% if ad.receivers and ad.receivers|length >= 1 %}
<div class="flex gap-0.5 justify-end mt-1">
{% for recv in ad.receivers %}
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
{% endfor %}
</div>
{% elif ad.received_by %}
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
{% endif %}
</div>
</div>
</div>
</a>
{% else %}
<div class="text-center py-8 opacity-70">No advertisements found.</div>
{% endfor %}
</div>
<!-- Advertisements Table - Desktop View -->
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
@@ -85,32 +159,5 @@
</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 }}&search={{ search }}&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 }}&search={{ search }}&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 }}&search={{ search }}&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 %}
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
{% endblock %}

View File

@@ -50,7 +50,7 @@
📦
{% endif %}
</span>
<div>
<div class="flex-1 min-w-0">
{% if display_name %}
<div class="font-medium text-sm">{{ display_name }}</div>
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
@@ -58,6 +58,9 @@
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
{% endif %}
</div>
{% if node.last_seen %}
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
{% endif %}
</a>
{% endfor %}
</div>

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Messages{% endblock %}
@@ -42,8 +43,10 @@
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
@@ -76,9 +79,6 @@
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
{% if msg.snr is not none %}
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(msg.snr) }}dB</span>
{% endif %}
{% if msg.receivers and msg.receivers|length >= 1 %}
<div class="flex gap-0.5">
{% for recv in msg.receivers %}
@@ -107,7 +107,6 @@
<th>Time</th>
<th>From/Channel</th>
<th>Message</th>
<th class="text-center">SNR</th>
<th>Receivers</th>
</tr>
</thead>
@@ -132,13 +131,6 @@
{% endif %}
</td>
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
<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>
{% if msg.receivers and msg.receivers|length >= 1 %}
<div class="flex gap-1">
@@ -155,39 +147,12 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
<td colspan="5" class="text-center py-8 opacity-70">No messages 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 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&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 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&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 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&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 %}
{{ pagination(page, total_pages, {"message_type": message_type, "channel_idx": channel_idx, "limit": limit}) }}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% block title %}{{ network_name }} - Nodes{% endblock %}
@@ -38,14 +39,79 @@
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
{% if members %}
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
</label>
<select name="member_id" class="select select-bordered select-sm">
<option value="">All Members</option>
{% for member in members %}
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Nodes Table -->
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
<!-- Nodes List - Mobile Card View -->
<div class="lg:hidden space-y-3">
{% for node in nodes %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div class="min-w-0">
{% if ns.tag_name or node.name %}
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
{% else %}
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">
{% if node.last_seen %}
{{ node.last_seen[:10] }}
{% else %}
-
{% endif %}
</div>
{% if node.tags %}
<div class="flex gap-1 justify-end mt-1">
{% for tag in node.tags[:2] %}
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
{% endfor %}
{% if node.tags|length > 2 %}
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</a>
{% else %}
<div class="text-center py-8 opacity-70">No nodes found.</div>
{% endfor %}
</div>
<!-- Nodes Table - Desktop View -->
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
@@ -107,32 +173,5 @@
</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 }}&search={{ search }}&adv_type={{ adv_type }}&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 }}&search={{ search }}&adv_type={{ adv_type }}&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 }}&search={{ search }}&adv_type={{ adv_type }}&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 %}
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
{% endblock %}