Merge pull request #22 from ipnet-mesh/claude/add-member-node-association-01JezMA7XzvwsX37rMNoBmvo

Updates
This commit is contained in:
JingleManSweep
2025-12-05 21:18:31 +00:00
committed by GitHub
8 changed files with 235 additions and 52 deletions
@@ -54,9 +54,7 @@ def upgrade() -> None:
# Get all members with a public_key
connection = op.get_bind()
members_with_keys = connection.execute(
sa.text(
"SELECT id, public_key FROM members WHERE public_key IS NOT NULL"
)
sa.text("SELECT id, public_key FROM members WHERE public_key IS NOT NULL")
).fetchall()
# Insert into member_nodes
+45 -1
View File
@@ -1,22 +1,66 @@
# IPNet Network Members
members:
- name: Louis
callsign: louis
callsign: Louis
role: admin
description: IPNet Founder
nodes:
# ip2-rep01
- public_key: 2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1
node_role: repeater
# ip2-rep02
- public_key: 8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90
node_role: repeater
# ip2-rep03
- public_key: 5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b
node_role: repeater
# ip2-sol01
- public_key: 87eb9487a1a4351e986e55627b2d09c4da61f94d080eaf4d7129caef89886e25
node_role: repeater
# personal chat node
- public_key: c6e0d85528b4b5d7f53aa7dded2b7e0b9c8f8a5c00acfaad47476ef5f3c7dc47
node_role: chat
- name: Mark
callsign: Mark
role: member
description: IPNet Member
nodes:
- public_key: 22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe
node_role: repeater
- public_key: 9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8
node_role: repeater
- public_key: 2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb
node_role: repeater
- public_key: e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac
node_role: repeater
- public_key: d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a
node_role: repeater
- public_key: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80
node_role: repeater
- name: CCZ
callsign: CCZ
role: member
nodes:
- public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459
node_role: repeater
- public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53
node_role: repeater
- public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac
node_role: repeater
description: IPNet Member
- name: Walshie
callsign: Walshie86
role: member
description: IPNet Member
nodes:
- public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52
node_role: repeater
- public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2
node_role: repeater
- name: Craig
callsign: M7XCN
role: member
description: IPNet Member
nodes:
- public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3
node_role: repeater
+98 -4
View File
@@ -6,10 +6,11 @@ from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Member, MemberNode
from meshcore_hub.common.models import Member, MemberNode, Node
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberList,
MemberNodeRead,
MemberRead,
MemberUpdate,
)
@@ -17,6 +18,50 @@ from meshcore_hub.common.schemas.members import (
router = APIRouter()
def _enrich_member_nodes(
member: Member, node_info: dict[str, dict]
) -> list[MemberNodeRead]:
"""Enrich member nodes with node details from the database.
Args:
member: The member with nodes to enrich
node_info: Dict mapping public_key to node details
Returns:
List of MemberNodeRead with node details populated
"""
enriched_nodes = []
for mn in member.nodes:
info = node_info.get(mn.public_key, {})
enriched_nodes.append(
MemberNodeRead(
public_key=mn.public_key,
node_role=mn.node_role,
created_at=mn.created_at,
updated_at=mn.updated_at,
node_name=info.get("name"),
node_adv_type=info.get("adv_type"),
friendly_name=info.get("friendly_name"),
)
)
return enriched_nodes
def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead:
"""Convert a Member model to MemberRead with enriched node data."""
return MemberRead(
id=member.id,
name=member.name,
callsign=member.callsign,
role=member.role,
description=member.description,
contact=member.contact,
nodes=_enrich_member_nodes(member, node_info),
created_at=member.created_at,
updated_at=member.updated_at,
)
@router.get("", response_model=MemberList)
async def list_members(
_: RequireRead,
@@ -37,10 +82,37 @@ async def list_members(
.limit(limit)
.offset(offset)
)
members = session.execute(query).scalars().all()
members = list(session.execute(query).scalars().all())
# Collect all public keys from member nodes
all_public_keys = set()
for m in members:
for mn in m.nodes:
all_public_keys.add(mn.public_key)
# Fetch node info for all public keys in one query
node_info: dict[str, dict] = {}
if all_public_keys:
node_query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key.in_(all_public_keys))
)
nodes = session.execute(node_query).scalars().all()
for node in nodes:
friendly_name = None
for tag in node.tags:
if tag.key == "friendly_name":
friendly_name = tag.value
break
node_info[node.public_key] = {
"name": node.name,
"adv_type": node.adv_type,
"friendly_name": friendly_name,
}
return MemberList(
items=[MemberRead.model_validate(m) for m in members],
items=[_member_to_read(m, node_info) for m in members],
total=total,
limit=limit,
offset=offset,
@@ -62,7 +134,29 @@ async def get_member(
if not member:
raise HTTPException(status_code=404, detail="Member not found")
return MemberRead.model_validate(member)
# Fetch node info for member's nodes
node_info: dict[str, dict] = {}
public_keys = [mn.public_key for mn in member.nodes]
if public_keys:
node_query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key.in_(public_keys))
)
nodes = session.execute(node_query).scalars().all()
for node in nodes:
friendly_name = None
for tag in node.tags:
if tag.key == "friendly_name":
friendly_name = tag.value
break
node_info[node.public_key] = {
"name": node.name,
"adv_type": node.adv_type,
"friendly_name": friendly_name,
}
return _member_to_read(member, node_info)
@router.post("", response_model=MemberRead, status_code=201)
@@ -30,6 +30,14 @@ class MemberNodeRead(BaseModel):
node_role: Optional[str] = Field(default=None, description="Role of the node")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
# Node details (populated from nodes table if available)
node_name: Optional[str] = Field(default=None, description="Node's name from DB")
node_adv_type: Optional[str] = Field(
default=None, description="Node's advertisement type"
)
friendly_name: Optional[str] = Field(
default=None, description="Node's friendly name tag"
)
class Config:
from_attributes = True
+13
View File
@@ -21,6 +21,15 @@ async def members_page(request: Request) -> HTMLResponse:
# Fetch members from API
members = []
def node_sort_key(node: dict) -> int:
"""Sort nodes: repeater first, then chat, then others."""
role = (node.get("node_role") or "").lower()
if role == "repeater":
return 0
if role == "chat":
return 1
return 2
try:
response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
@@ -28,6 +37,10 @@ async def members_page(request: Request) -> HTMLResponse:
if response.status_code == 200:
data = response.json()
members = data.get("items", [])
# Sort nodes within each member (repeater first, then chat)
for member in members:
if member.get("nodes"):
member["nodes"] = sorted(member["nodes"], key=node_sort_key)
except Exception as e:
logger.warning(f"Failed to fetch members from API: {e}")
context["api_error"] = str(e)
+51 -43
View File
@@ -9,43 +9,55 @@
</div>
{% if members %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
{% for member in members %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
{{ member.name }}
{% if member.callsign %}
<span class="badge badge-secondary">{{ member.callsign }}</span>
<span class="badge badge-success">{{ member.callsign }}</span>
{% endif %}
</h2>
{% if member.role %}
<p class="text-sm opacity-70">{{ member.role }}</p>
{% endif %}
{% if member.description %}
<p class="mt-2">{{ member.description }}</p>
{% endif %}
{% if member.email or member.discord or member.website %}
<div class="card-actions justify-start mt-4">
{% if member.email %}
<a href="mailto:{{ member.email }}" class="btn btn-ghost btn-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Email
</a>
{% endif %}
{% if member.website %}
<a href="{{ member.website }}" target="_blank" class="btn btn-ghost btn-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Website
</a>
{% endif %}
{% if member.contact %}
<p class="text-sm mt-2">
<span class="opacity-70">Contact:</span> {{ member.contact }}
</p>
{% endif %}
{% if member.nodes %}
<div class="mt-4 space-y-2">
{% for node in member.nodes %}
{% set adv_type = node.node_adv_type or node.node_role %}
<a href="/nodes/{{ node.public_key }}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<span class="text-lg" title="{{ adv_type or 'Unknown' }}">
{% if adv_type and adv_type|lower == 'chat' %}
💬
{% elif adv_type and adv_type|lower == 'repeater' %}
📡
{% elif adv_type and adv_type|lower == 'room' %}
🪧
{% elif adv_type %}
📍
{% else %}
📦
{% endif %}
</span>
<div>
{% if node.friendly_name or node.node_name %}
<div class="font-medium text-sm">{{ node.friendly_name or node.node_name }}</div>
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
{% else %}
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
@@ -59,31 +71,27 @@
</svg>
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, provide a members JSON file using the <code>--members-file</code> option.</p>
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Members File Format</h2>
<p class="mb-4">Create a JSON file with the following structure:</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>{
"members": [
{
"name": "John Doe",
"callsign": "AB1CD",
"role": "Network Admin",
"description": "Manages the main repeater node.",
"email": "john@example.com",
"website": "https://example.com"
},
{
"name": "Jane Smith",
"role": "Member",
"description": "Regular user in the downtown area."
}
]
}</code></pre>
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
- name: John Doe
callsign: AB1CD
role: Network Admin
description: Manages the main repeater node.
contact: john@example.com
nodes:
- public_key: abc123def456... # 64-char hex
node_role: repeater
- name: Jane Smith
role: Member
description: Regular user in the downtown area.</code></pre>
<p class="mt-4 text-sm opacity-70">Run <code>meshcore-hub collector seed</code> to import members, or they will be imported automatically on collector startup.</p>
</div>
</div>
{% endif %}
+3
View File
@@ -321,6 +321,9 @@ def mock_http_client_with_members() -> MockHttpClient:
"node_role": "chat",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"node_name": "Alice's Node",
"node_adv_type": "chat",
"friendly_name": "Alice Chat",
}
],
"created_at": "2024-01-01T00:00:00Z",
+16 -1
View File
@@ -38,4 +38,19 @@ class TestMembersPage:
assert "Bob" in response.text
assert "W1ABC" in response.text
assert "W2XYZ" in response.text
assert "Admin" in response.text
def test_members_with_nodes_shows_node_links(
self, client_with_members: TestClient
) -> None:
"""Test that members page shows associated nodes with links."""
response = client_with_members.get("/members")
assert response.status_code == 200
# Alice has a node associated - check for friendly name display
assert "Alice Chat" in response.text
# Check for partial public key underneath
assert "abc123def456" in response.text
# Check for link to node detail page (full public key)
assert (
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
in response.text
)