forked from iarv/meshcore-hub
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a290db0491 | ||
|
|
92b0b883e6 | ||
|
|
9e621c0029 | ||
|
|
a251f3a09f | ||
|
|
0fdedfe5ba | ||
|
|
243a3e8521 | ||
|
|
b24a6f0894 | ||
|
|
57f51c741c | ||
|
|
65b8418af4 |
@@ -0,0 +1,39 @@
|
||||
"""Make Node.last_seen nullable
|
||||
|
||||
Revision ID: 0b944542ccd8
|
||||
Revises: 005
|
||||
Create Date: 2025-12-08 00:07:49.891245+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0b944542ccd8"
|
||||
down_revision: Union[str, None] = "005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Make Node.last_seen nullable since nodes from contact sync
|
||||
# haven't actually been "seen" on the mesh yet
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Revert Node.last_seen to non-nullable
|
||||
# Note: This will fail if there are NULL values in last_seen
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Add member_id field to members table
|
||||
|
||||
Revision ID: 03b9b2451bd9
|
||||
Revises: 0b944542ccd8
|
||||
Create Date: 2025-12-08 14:34:30.337799+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "03b9b2451bd9"
|
||||
down_revision: Union[str, None] = "0b944542ccd8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("advertisements", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_advertisements_event_hash_unique"))
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_advertisements_event_hash", ["event_hash"]
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
# Add member_id as nullable first to handle existing data
|
||||
batch_op.add_column(
|
||||
sa.Column("member_id", sa.String(length=100), nullable=True)
|
||||
)
|
||||
|
||||
# Generate member_id for existing members based on their name
|
||||
# Convert name to lowercase and replace spaces with underscores
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"UPDATE members SET member_id = LOWER(REPLACE(name, ' ', '_')) WHERE member_id IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
# Now make it non-nullable and add unique index
|
||||
batch_op.alter_column("member_id", nullable=False)
|
||||
batch_op.drop_index(batch_op.f("ix_members_name"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_members_member_id"), ["member_id"], unique=True
|
||||
)
|
||||
|
||||
with op.batch_alter_table("messages", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_messages_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_messages_event_hash", ["event_hash"])
|
||||
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=True
|
||||
)
|
||||
|
||||
with op.batch_alter_table("telemetry", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_telemetry_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_telemetry_event_hash", ["event_hash"])
|
||||
|
||||
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_trace_paths_event_hash_unique"))
|
||||
batch_op.create_unique_constraint("uq_trace_paths_event_hash", ["event_hash"])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_trace_paths_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_trace_paths_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("telemetry", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_telemetry_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_telemetry_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=False
|
||||
)
|
||||
|
||||
with op.batch_alter_table("messages", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_messages_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_messages_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
with op.batch_alter_table("members", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_members_member_id"))
|
||||
batch_op.create_index(batch_op.f("ix_members_name"), ["name"], unique=False)
|
||||
batch_op.drop_column("member_id")
|
||||
|
||||
with op.batch_alter_table("advertisements", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("uq_advertisements_event_hash", type_="unique")
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_advertisements_event_hash_unique"), ["event_hash"], unique=1
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Remove member_nodes table
|
||||
|
||||
Revision ID: aa1162502616
|
||||
Revises: 03b9b2451bd9
|
||||
Create Date: 2025-12-08 15:04:37.260923+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "aa1162502616"
|
||||
down_revision: Union[str, None] = "03b9b2451bd9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the member_nodes table
|
||||
# Nodes are now associated with members via a 'member_id' tag on the node
|
||||
op.drop_table("member_nodes")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the member_nodes table if needed for rollback
|
||||
op.create_table(
|
||||
"member_nodes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("member_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("public_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("node_role", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["member_id"],
|
||||
["members.id"],
|
||||
name=op.f("fk_member_nodes_member_id_members"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_member_nodes")),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_member_nodes_member_id"), "member_nodes", ["member_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_member_nodes_public_key"), "member_nodes", ["public_key"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"ix_member_nodes_member_public_key",
|
||||
"member_nodes",
|
||||
["member_id", "public_key"],
|
||||
unique=False,
|
||||
)
|
||||
@@ -138,6 +138,9 @@ services:
|
||||
- all
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
seed:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- ${SEED_HOME:-./seed}:/seed
|
||||
@@ -193,6 +196,8 @@ services:
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
seed:
|
||||
condition: service_completed_successfully
|
||||
collector:
|
||||
condition: service_started
|
||||
ports:
|
||||
@@ -274,7 +279,9 @@ services:
|
||||
container_name: meshcore-db-migrate
|
||||
profiles:
|
||||
- all
|
||||
- core
|
||||
- migrate
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
@@ -295,7 +302,12 @@ services:
|
||||
container_name: meshcore-seed
|
||||
profiles:
|
||||
- all
|
||||
- core
|
||||
- seed
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
# Mount data directory for database (read-write)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# Example members seed file
|
||||
# Each member can have multiple nodes with different roles (chat, repeater, etc.)
|
||||
# Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
# Use node_tags.yaml to set member_id tags on nodes.
|
||||
members:
|
||||
- name: Example Member
|
||||
- member_id: example_member
|
||||
name: Example Member
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member entry with multiple nodes
|
||||
nodes:
|
||||
- public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
node_role: chat
|
||||
- public_key: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
|
||||
node_role: repeater
|
||||
- name: Simple Member
|
||||
description: Example network operator member
|
||||
- member_id: simple_member
|
||||
name: Simple Member
|
||||
callsign: N0CALL2
|
||||
role: Observer
|
||||
description: Member without any nodes
|
||||
description: Example observer member
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
@@ -89,6 +89,9 @@ def _fetch_receivers_for_events(
|
||||
async def list_advertisements(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
public_key: Optional[str] = Query(None, description="Filter by public key"),
|
||||
received_by: Optional[str] = Query(
|
||||
None, description="Filter by receiver node public key"
|
||||
@@ -118,6 +121,22 @@ async def list_advertisements(
|
||||
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
|
||||
)
|
||||
|
||||
if search:
|
||||
# Search in public key, advertisement name, node name, or name tag
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Advertisement.public_key.ilike(search_pattern),
|
||||
Advertisement.name.ilike(search_pattern),
|
||||
SourceNode.name.ilike(search_pattern),
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if public_key:
|
||||
query = query.where(Advertisement.public_key == public_key)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ async def get_stats(
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
|
||||
# Total nodes
|
||||
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
|
||||
@@ -73,6 +74,26 @@ async def get_stats(
|
||||
or 0
|
||||
)
|
||||
|
||||
# Advertisements in last 7 days
|
||||
advertisements_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Advertisement)
|
||||
.where(Advertisement.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Messages in last 7 days
|
||||
messages_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Recent advertisements (last 10)
|
||||
recent_ads = (
|
||||
session.execute(
|
||||
@@ -185,8 +206,10 @@ async def get_stats(
|
||||
active_nodes=active_nodes,
|
||||
total_messages=total_messages,
|
||||
messages_today=messages_today,
|
||||
messages_7d=messages_7d,
|
||||
total_advertisements=total_advertisements,
|
||||
advertisements_24h=advertisements_24h,
|
||||
advertisements_7d=advertisements_7d,
|
||||
recent_advertisements=recent_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
channel_messages=channel_messages,
|
||||
@@ -205,15 +228,15 @@ async def get_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily advertisement counts for each day in the period
|
||||
Daily advertisement counts for each day in the period (excluding today)
|
||||
"""
|
||||
# Limit to max 90 days
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query advertisement counts grouped by date
|
||||
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
|
||||
@@ -225,6 +248,7 @@ async def get_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Advertisement.received_at >= start_date)
|
||||
.where(Advertisement.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -257,14 +281,14 @@ async def get_message_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily message counts for each day in the period
|
||||
Daily message counts for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query message counts grouped by date
|
||||
date_expr = func.date(Message.received_at)
|
||||
@@ -275,6 +299,7 @@ async def get_message_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Message.received_at >= start_date)
|
||||
.where(Message.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -308,14 +333,14 @@ async def get_node_count_history(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Cumulative node count for each day in the period
|
||||
Cumulative node count for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Get all nodes with their creation dates
|
||||
# Count nodes created on or before each date
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
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, Node
|
||||
from meshcore_hub.common.models import Member
|
||||
from meshcore_hub.common.schemas.members import (
|
||||
MemberCreate,
|
||||
MemberList,
|
||||
MemberNodeRead,
|
||||
MemberRead,
|
||||
MemberUpdate,
|
||||
)
|
||||
@@ -18,50 +16,6 @@ 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"),
|
||||
tag_name=info.get("tag_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,
|
||||
@@ -74,45 +28,12 @@ async def list_members(
|
||||
count_query = select(func.count()).select_from(Member)
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Get members with nodes eagerly loaded
|
||||
query = (
|
||||
select(Member)
|
||||
.options(selectinload(Member.nodes))
|
||||
.order_by(Member.name)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
# Get members ordered by name
|
||||
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
|
||||
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:
|
||||
tag_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "name":
|
||||
tag_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"tag_name": tag_name,
|
||||
}
|
||||
|
||||
return MemberList(
|
||||
items=[_member_to_read(m, node_info) for m in members],
|
||||
items=[MemberRead.model_validate(m) for m in members],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -126,37 +47,13 @@ async def get_member(
|
||||
member_id: str,
|
||||
) -> MemberRead:
|
||||
"""Get a specific member by ID."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
member = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# 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:
|
||||
tag_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "name":
|
||||
tag_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"tag_name": tag_name,
|
||||
}
|
||||
|
||||
return _member_to_read(member, node_info)
|
||||
return MemberRead.model_validate(member)
|
||||
|
||||
|
||||
@router.post("", response_model=MemberRead, status_code=201)
|
||||
@@ -166,8 +63,18 @@ async def create_member(
|
||||
member: MemberCreate,
|
||||
) -> MemberRead:
|
||||
"""Create a new member."""
|
||||
# Check if member_id already exists
|
||||
query = select(Member).where(Member.member_id == member.member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
|
||||
# Create member
|
||||
new_member = Member(
|
||||
member_id=member.member_id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
@@ -175,18 +82,6 @@ async def create_member(
|
||||
contact=member.contact,
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member.nodes:
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(new_member)
|
||||
|
||||
@@ -201,15 +96,25 @@ async def update_member(
|
||||
member: MemberUpdate,
|
||||
) -> MemberRead:
|
||||
"""Update a member."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Update fields
|
||||
if member.member_id is not None:
|
||||
# Check if new member_id is already taken by another member
|
||||
check_query = select(Member).where(
|
||||
Member.member_id == member.member_id, Member.id != member_id
|
||||
)
|
||||
collision = session.execute(check_query).scalar_one_or_none()
|
||||
if collision:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
existing.member_id = member.member_id
|
||||
if member.name is not None:
|
||||
existing.name = member.name
|
||||
if member.callsign is not None:
|
||||
@@ -221,20 +126,6 @@ async def update_member(
|
||||
if member.contact is not None:
|
||||
existing.contact = member.contact
|
||||
|
||||
# Update nodes if provided (replaces existing nodes)
|
||||
if member.nodes is not None:
|
||||
# Clear existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
|
||||
|
||||
router = APIRouter()
|
||||
@@ -17,18 +18,31 @@ router = APIRouter()
|
||||
async def list_nodes(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(None, description="Search in name or public key"),
|
||||
search: Optional[str] = Query(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
"""List all nodes with pagination and filtering."""
|
||||
# Build query
|
||||
query = select(Node)
|
||||
# Build base query with tags loaded
|
||||
query = select(Node).options(selectinload(Node.tags))
|
||||
|
||||
if search:
|
||||
# Search in public key, node name, or name tag
|
||||
# For name tag search, we need to join with NodeTag
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
(Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
|
||||
or_(
|
||||
Node.public_key.ilike(search_pattern),
|
||||
Node.name.ilike(search_pattern),
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if adv_type:
|
||||
@@ -38,7 +52,7 @@ async def list_nodes(
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
|
||||
@@ -170,8 +170,8 @@ def _run_collector_service(
|
||||
) -> None:
|
||||
"""Run the collector service.
|
||||
|
||||
On startup, automatically seeds the database from YAML files in seed_home
|
||||
if they exist.
|
||||
Note: Seed data import should be done using the 'meshcore-hub collector seed'
|
||||
command or the dedicated seed container before starting the collector service.
|
||||
|
||||
Webhooks can be configured via environment variables:
|
||||
- WEBHOOK_ADVERTISEMENT_URL: Webhook for advertisement events
|
||||
@@ -193,31 +193,6 @@ def _run_collector_service(
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Database: {database_url}")
|
||||
|
||||
# Initialize database (schema managed by Alembic migrations)
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
db = DatabaseManager(database_url)
|
||||
|
||||
# Auto-seed from seed files on startup
|
||||
click.echo("")
|
||||
click.echo("Checking for seed files...")
|
||||
seed_home_path = Path(seed_home)
|
||||
node_tags_exists = (seed_home_path / "node_tags.yaml").exists()
|
||||
members_exists = (seed_home_path / "members.yaml").exists()
|
||||
|
||||
if node_tags_exists or members_exists:
|
||||
click.echo("Running seed import...")
|
||||
_run_seed_import(
|
||||
seed_home=seed_home,
|
||||
db=db,
|
||||
create_nodes=True,
|
||||
verbose=True,
|
||||
)
|
||||
else:
|
||||
click.echo(f"No seed files found in {seed_home}")
|
||||
|
||||
db.dispose()
|
||||
|
||||
# Load webhook configuration from settings
|
||||
from meshcore_hub.collector.webhook import (
|
||||
WebhookDispatcher,
|
||||
@@ -688,3 +663,212 @@ def cleanup_cmd(
|
||||
db.dispose()
|
||||
click.echo("")
|
||||
click.echo("Cleanup complete." if not dry_run else "Dry run complete.")
|
||||
|
||||
|
||||
@collector.command("truncate")
|
||||
@click.option(
|
||||
"--members",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate members table",
|
||||
)
|
||||
@click.option(
|
||||
"--nodes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate nodes table (also clears tags, advertisements, messages, telemetry, trace paths)",
|
||||
)
|
||||
@click.option(
|
||||
"--messages",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate messages table",
|
||||
)
|
||||
@click.option(
|
||||
"--advertisements",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate advertisements table",
|
||||
)
|
||||
@click.option(
|
||||
"--telemetry",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate telemetry table",
|
||||
)
|
||||
@click.option(
|
||||
"--trace-paths",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate trace_paths table",
|
||||
)
|
||||
@click.option(
|
||||
"--event-logs",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate event_logs table",
|
||||
)
|
||||
@click.option(
|
||||
"--all",
|
||||
"truncate_all",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate ALL tables (use with caution!)",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
@click.pass_context
|
||||
def truncate_cmd(
|
||||
ctx: click.Context,
|
||||
members: bool,
|
||||
nodes: bool,
|
||||
messages: bool,
|
||||
advertisements: bool,
|
||||
telemetry: bool,
|
||||
trace_paths: bool,
|
||||
event_logs: bool,
|
||||
truncate_all: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""Truncate (clear) data tables.
|
||||
|
||||
WARNING: This permanently deletes data! Use with caution.
|
||||
|
||||
Examples:
|
||||
# Clear members table
|
||||
meshcore-hub collector truncate --members
|
||||
|
||||
# Clear messages and advertisements
|
||||
meshcore-hub collector truncate --messages --advertisements
|
||||
|
||||
# Clear everything (requires confirmation)
|
||||
meshcore-hub collector truncate --all
|
||||
|
||||
Note: Clearing nodes also clears all related data (tags, advertisements,
|
||||
messages, telemetry, trace paths) due to foreign key constraints.
|
||||
"""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
# Determine what to truncate
|
||||
if truncate_all:
|
||||
tables_to_clear = {
|
||||
"members": True,
|
||||
"nodes": True,
|
||||
"messages": True,
|
||||
"advertisements": True,
|
||||
"telemetry": True,
|
||||
"trace_paths": True,
|
||||
"event_logs": True,
|
||||
}
|
||||
else:
|
||||
tables_to_clear = {
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
"messages": messages,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"trace_paths": trace_paths,
|
||||
"event_logs": event_logs,
|
||||
}
|
||||
|
||||
# Check if any tables selected
|
||||
if not any(tables_to_clear.values()):
|
||||
click.echo("No tables specified. Use --help to see available options.")
|
||||
return
|
||||
|
||||
# Show what will be cleared
|
||||
click.echo("Database: " + ctx.obj["database_url"])
|
||||
click.echo("")
|
||||
click.echo("The following tables will be PERMANENTLY CLEARED:")
|
||||
for table, should_clear in tables_to_clear.items():
|
||||
if should_clear:
|
||||
click.echo(f" - {table}")
|
||||
|
||||
if tables_to_clear.get("nodes"):
|
||||
click.echo("")
|
||||
click.echo(
|
||||
"WARNING: Clearing nodes will also clear all related data due to foreign keys:"
|
||||
)
|
||||
click.echo(" - node_tags")
|
||||
click.echo(" - advertisements")
|
||||
click.echo(" - messages")
|
||||
click.echo(" - telemetry")
|
||||
click.echo(" - trace_paths")
|
||||
|
||||
click.echo("")
|
||||
|
||||
# Confirm
|
||||
if not yes:
|
||||
if not click.confirm(
|
||||
"Are you sure you want to permanently delete this data?", default=False
|
||||
):
|
||||
click.echo("Aborted.")
|
||||
return
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Truncate in correct order to respect foreign keys
|
||||
cleared: list[str] = []
|
||||
|
||||
# Clear members (no dependencies)
|
||||
if tables_to_clear.get("members"):
|
||||
result: CursorResult = session.execute(delete(Member)) # type: ignore
|
||||
cleared.append(f"members: {result.rowcount} rows")
|
||||
|
||||
# Clear event-specific tables first (they depend on nodes)
|
||||
if tables_to_clear.get("messages"):
|
||||
result = session.execute(delete(Message)) # type: ignore
|
||||
cleared.append(f"messages: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("advertisements"):
|
||||
result = session.execute(delete(Advertisement)) # type: ignore
|
||||
cleared.append(f"advertisements: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("telemetry"):
|
||||
result = session.execute(delete(Telemetry)) # type: ignore
|
||||
cleared.append(f"telemetry: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("trace_paths"):
|
||||
result = session.execute(delete(TracePath)) # type: ignore
|
||||
cleared.append(f"trace_paths: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("event_logs"):
|
||||
result = session.execute(delete(EventLog)) # type: ignore
|
||||
cleared.append(f"event_logs: {result.rowcount} rows")
|
||||
|
||||
# Clear nodes last (this will cascade delete tags and any remaining events)
|
||||
if tables_to_clear.get("nodes"):
|
||||
# Delete tags first (they depend on nodes)
|
||||
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
|
||||
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
|
||||
|
||||
# Delete nodes (will cascade to remaining related tables)
|
||||
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
|
||||
cleared.append(f"nodes: {node_result.rowcount} rows")
|
||||
|
||||
db.dispose()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Truncate complete. Cleared:")
|
||||
for item in cleared:
|
||||
click.echo(f" - {item}")
|
||||
click.echo("")
|
||||
|
||||
@@ -73,15 +73,17 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
node.last_seen = now
|
||||
# Do NOT update last_seen for contact sync - only advertisement events
|
||||
# should update last_seen since that's when the node was actually seen
|
||||
else:
|
||||
# Create new node
|
||||
# Create new node from contact database
|
||||
# Set last_seen=None since we haven't actually seen this node advertise yet
|
||||
node = Node(
|
||||
public_key=contact_key,
|
||||
name=name,
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
last_seen=None, # Will be set when we receive an advertisement
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -5,41 +5,28 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Member, MemberNode
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NodeData(BaseModel):
|
||||
"""Schema for a node entry in the member import file."""
|
||||
|
||||
public_key: str = Field(..., min_length=64, max_length=64)
|
||||
node_role: Optional[str] = Field(default=None, max_length=50)
|
||||
|
||||
@field_validator("public_key")
|
||||
@classmethod
|
||||
def validate_public_key(cls, v: str) -> str:
|
||||
"""Validate and normalize public key."""
|
||||
if len(v) != 64:
|
||||
raise ValueError(f"public_key must be 64 characters, got {len(v)}")
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in v):
|
||||
raise ValueError("public_key must be a valid hex string")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class MemberData(BaseModel):
|
||||
"""Schema for a member entry in the import file."""
|
||||
"""Schema for a member entry in the import file.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
callsign: Optional[str] = Field(default=None, max_length=20)
|
||||
role: Optional[str] = Field(default=None, max_length=100)
|
||||
description: Optional[str] = Field(default=None)
|
||||
contact: Optional[str] = Field(default=None, max_length=255)
|
||||
nodes: Optional[list[NodeData]] = Field(default=None)
|
||||
|
||||
|
||||
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
@@ -48,20 +35,16 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
Supports two formats:
|
||||
1. List of member objects:
|
||||
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
2. Object with "members" key:
|
||||
|
||||
members:
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -96,6 +79,8 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
for i, member in enumerate(members_list):
|
||||
if not isinstance(member, dict):
|
||||
raise ValueError(f"Member at index {i} must be an object")
|
||||
if "member_id" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'member_id' field")
|
||||
if "name" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'name' field")
|
||||
|
||||
@@ -115,9 +100,11 @@ def import_members(
|
||||
) -> dict[str, Any]:
|
||||
"""Import members from a YAML file into the database.
|
||||
|
||||
Performs upsert operations based on name - existing members are updated,
|
||||
new members are created. Nodes are synced (existing nodes removed and
|
||||
replaced with new ones from the file).
|
||||
Performs upsert operations based on member_id - existing members are updated,
|
||||
new members are created.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
This import does not manage node associations.
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -149,14 +136,17 @@ def import_members(
|
||||
with db.session_scope() as session:
|
||||
for member_data in members_data:
|
||||
try:
|
||||
member_id = member_data["member_id"]
|
||||
name = member_data["name"]
|
||||
|
||||
# Find existing member by name
|
||||
query = select(Member).where(Member.name == name)
|
||||
# Find existing member by member_id
|
||||
query = select(Member).where(Member.member_id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update existing member
|
||||
if member_data.get("name") is not None:
|
||||
existing.name = member_data["name"]
|
||||
if member_data.get("callsign") is not None:
|
||||
existing.callsign = member_data["callsign"]
|
||||
if member_data.get("role") is not None:
|
||||
@@ -166,25 +156,12 @@ def import_members(
|
||||
if member_data.get("contact") is not None:
|
||||
existing.contact = member_data["contact"]
|
||||
|
||||
# Sync nodes if provided
|
||||
if member_data.get("nodes") is not None:
|
||||
# Remove existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"Updated member: {name}")
|
||||
logger.debug(f"Updated member: {member_id} ({name})")
|
||||
else:
|
||||
# Create new member
|
||||
new_member = Member(
|
||||
member_id=member_id,
|
||||
name=name,
|
||||
callsign=member_data.get("callsign"),
|
||||
role=member_data.get("role"),
|
||||
@@ -192,23 +169,12 @@ def import_members(
|
||||
contact=member_data.get("contact"),
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member_data.get("nodes"):
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.debug(f"Created member: {name}")
|
||||
logger.debug(f"Created member: {member_id} ({name})")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing member '{member_data.get('name', 'unknown')}': {e}"
|
||||
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
|
||||
stats["errors"].append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from meshcore_hub.common.models.trace_path import TracePath
|
||||
from meshcore_hub.common.models.telemetry import Telemetry
|
||||
from meshcore_hub.common.models.event_log import EventLog
|
||||
from meshcore_hub.common.models.member import Member
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
from meshcore_hub.common.models.event_receiver import EventReceiver, add_event_receiver
|
||||
|
||||
__all__ = [
|
||||
@@ -23,7 +22,6 @@ __all__ = [
|
||||
"Telemetry",
|
||||
"EventLog",
|
||||
"Member",
|
||||
"MemberNode",
|
||||
"EventReceiver",
|
||||
"add_event_receiver",
|
||||
]
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
"""Member model for network member information."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
|
||||
|
||||
class Member(Base, UUIDMixin, TimestampMixin):
|
||||
"""Member model for network member information.
|
||||
|
||||
Stores information about network members/operators.
|
||||
Members can have multiple associated nodes (chat, repeater, etc.).
|
||||
Nodes are associated with members via a 'member_id' tag on the node.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Unique member identifier (e.g., 'walshie86')
|
||||
name: Member's display name
|
||||
callsign: Amateur radio callsign (optional)
|
||||
role: Member's role in the network (optional)
|
||||
description: Additional description (optional)
|
||||
contact: Contact information (optional)
|
||||
nodes: List of associated MemberNode records
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "members"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
@@ -52,11 +55,5 @@ class Member(Base, UUIDMixin, TimestampMixin):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship to member nodes
|
||||
nodes: Mapped[list["MemberNode"]] = relationship(
|
||||
back_populates="member",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, name={self.name}, callsign={self.callsign})>"
|
||||
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""MemberNode model for associating nodes with members."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member import Member
|
||||
|
||||
|
||||
class MemberNode(Base, UUIDMixin, TimestampMixin):
|
||||
"""Association model linking members to their nodes.
|
||||
|
||||
A member can have multiple nodes (e.g., chat node, repeater).
|
||||
Each node is identified by its public_key and has a role.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Foreign key to the member
|
||||
public_key: Node's public key (64-char hex)
|
||||
node_role: Role of the node (e.g., 'chat', 'repeater')
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "member_nodes"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("members.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_role: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship back to member
|
||||
member: Mapped["Member"] = relationship(back_populates="nodes")
|
||||
|
||||
# Composite index for efficient lookups
|
||||
__table_args__ = (
|
||||
Index("ix_member_nodes_member_public_key", "member_id", "public_key"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MemberNode(member_id={self.member_id}, public_key={self.public_key[:8]}..., role={self.node_role})>"
|
||||
@@ -52,10 +52,10 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
last_seen: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -6,44 +6,19 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemberNodeCreate(BaseModel):
|
||||
"""Schema for creating a member node association."""
|
||||
|
||||
public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description="Node's public key (64-char hex)",
|
||||
)
|
||||
node_role: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=50,
|
||||
description="Role of the node (e.g., 'chat', 'repeater')",
|
||||
)
|
||||
|
||||
|
||||
class MemberNodeRead(BaseModel):
|
||||
"""Schema for reading a member node association."""
|
||||
|
||||
public_key: str = Field(..., description="Node's public key")
|
||||
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"
|
||||
)
|
||||
tag_name: Optional[str] = Field(default=None, description="Node's name tag")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Schema for creating a member."""
|
||||
"""Schema for creating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
@@ -69,15 +44,21 @@ class MemberCreate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes",
|
||||
)
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Schema for updating a member."""
|
||||
"""Schema for updating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
@@ -103,22 +84,22 @@ class MemberUpdate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes (replaces existing nodes)",
|
||||
)
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
"""Schema for reading a member."""
|
||||
"""Schema for reading a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Member UUID")
|
||||
member_id: str = Field(..., description="Unique member identifier")
|
||||
name: str = Field(..., description="Member's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
role: Optional[str] = Field(default=None, description="Member's role")
|
||||
description: Optional[str] = Field(default=None, description="Description")
|
||||
contact: Optional[str] = Field(default=None, description="Contact information")
|
||||
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -239,10 +239,14 @@ class DashboardStats(BaseModel):
|
||||
active_nodes: int = Field(..., description="Nodes active in last 24h")
|
||||
total_messages: int = Field(..., description="Total number of messages")
|
||||
messages_today: int = Field(..., description="Messages received today")
|
||||
messages_7d: int = Field(default=0, description="Messages received in last 7 days")
|
||||
total_advertisements: int = Field(..., description="Total advertisements")
|
||||
advertisements_24h: int = Field(
|
||||
default=0, description="Advertisements received in last 24h"
|
||||
)
|
||||
advertisements_7d: int = Field(
|
||||
default=0, description="Advertisements received in last 7 days"
|
||||
)
|
||||
recent_advertisements: list[RecentAdvertisement] = Field(
|
||||
default_factory=list, description="Last 10 advertisements"
|
||||
)
|
||||
|
||||
@@ -59,7 +59,9 @@ class NodeRead(BaseModel):
|
||||
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
first_seen: datetime = Field(..., description="First advertisement timestamp")
|
||||
last_seen: datetime = Field(..., description="Last activity timestamp")
|
||||
last_seen: Optional[datetime] = Field(
|
||||
default=None, description="Last activity timestamp"
|
||||
)
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Record update timestamp")
|
||||
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
|
||||
@@ -82,7 +84,7 @@ class NodeFilters(BaseModel):
|
||||
|
||||
search: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Search in name or public key",
|
||||
description="Search in name tag, node name, or public key",
|
||||
)
|
||||
adv_type: Optional[str] = Field(
|
||||
default=None,
|
||||
|
||||
@@ -14,7 +14,7 @@ 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"),
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -28,8 +28,8 @@ async def advertisements_list(
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if public_key:
|
||||
params["public_key"] = public_key
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
@@ -57,7 +57,7 @@ async def advertisements_list(
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"public_key": public_key or "",
|
||||
"search": search or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -23,24 +23,72 @@ async def members_page(request: Request) -> HTMLResponse:
|
||||
|
||||
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":
|
||||
adv_type = (node.get("adv_type") or "").lower()
|
||||
if adv_type == "repeater":
|
||||
return 0
|
||||
if role == "chat":
|
||||
if adv_type == "chat":
|
||||
return 1
|
||||
return 2
|
||||
|
||||
try:
|
||||
# Fetch all members
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
# Sort nodes within each member (repeater first, then chat)
|
||||
|
||||
# Fetch all nodes with member_id tags in one query
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"has_tag": "member_id", "limit": 500}
|
||||
)
|
||||
|
||||
# Build a map of member_id -> nodes
|
||||
member_nodes_map: dict[str, list] = {}
|
||||
if nodes_response.status_code == 200:
|
||||
nodes_data = nodes_response.json()
|
||||
all_nodes = nodes_data.get("items", [])
|
||||
|
||||
for node in all_nodes:
|
||||
# Find member_id tag
|
||||
for tag in node.get("tags", []):
|
||||
if tag.get("key") == "member_id":
|
||||
member_id_value = tag.get("value")
|
||||
if member_id_value:
|
||||
if member_id_value not in member_nodes_map:
|
||||
member_nodes_map[member_id_value] = []
|
||||
member_nodes_map[member_id_value].append(node)
|
||||
break
|
||||
|
||||
# Assign nodes to members and sort
|
||||
for member in members:
|
||||
if member.get("nodes"):
|
||||
member["nodes"] = sorted(member["nodes"], key=node_sort_key)
|
||||
member_id = member.get("member_id")
|
||||
if member_id and member_id in member_nodes_map:
|
||||
# Sort nodes (repeater first, then chat, then by name tag)
|
||||
nodes = member_nodes_map[member_id]
|
||||
|
||||
# Sort by advertisement type first, then by name
|
||||
def full_sort_key(node: dict) -> tuple:
|
||||
adv_type = (node.get("adv_type") or "").lower()
|
||||
type_priority = (
|
||||
0
|
||||
if adv_type == "repeater"
|
||||
else (1 if adv_type == "chat" else 2)
|
||||
)
|
||||
|
||||
# Get name from tags
|
||||
node_name = node.get("name") or ""
|
||||
for tag in node.get("tags", []):
|
||||
if tag.get("key") == "name":
|
||||
node_name = tag.get("value") or node_name
|
||||
break
|
||||
|
||||
return (type_priority, node_name.lower())
|
||||
|
||||
member["nodes"] = sorted(nodes, key=full_sort_key)
|
||||
else:
|
||||
member["nodes"] = []
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch members from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
<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>
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="public_key" value="{{ public_key }}" placeholder="Filter by public key..." class="input input-bordered input-sm w-80" />
|
||||
<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">Filter</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Search</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -135,14 +135,14 @@
|
||||
{% 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>
|
||||
<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 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
<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 %}
|
||||
|
||||
@@ -19,25 +19,25 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-4 justify-center flex-wrap">
|
||||
<a href="/network" class="btn btn-primary">
|
||||
<a href="/network" class="btn btn-neutral">
|
||||
<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>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-secondary">
|
||||
<a href="/nodes" class="btn btn-primary">
|
||||
<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>
|
||||
Nodes
|
||||
</a>
|
||||
<a href="/advertisements" class="btn btn-accent">
|
||||
<a href="/advertisements" 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="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
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-info">
|
||||
<a href="/messages" 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="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>
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-primary">
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (24h) -->
|
||||
<!-- Advertisements (7 days) -->
|
||||
<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">
|
||||
@@ -70,32 +70,20 @@
|
||||
</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 class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Messages -->
|
||||
<!-- Messages (7 days) -->
|
||||
<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 class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,8 +227,8 @@
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: counts,
|
||||
borderColor: 'oklch(0.7 0.15 250)',
|
||||
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
{% 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 %}
|
||||
{% set adv_type = node.adv_type %}
|
||||
{% set node_tag_name = node.tags|selectattr('key', 'equalto', 'name')|map(attribute='value')|first %}
|
||||
{% set display_name = node_tag_name or node.name %}
|
||||
<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' %}
|
||||
@@ -49,8 +51,8 @@
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
@@ -80,18 +82,20 @@
|
||||
<h2 class="card-title">Members File Format</h2>
|
||||
<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
|
||||
- member_id: johndoe
|
||||
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
|
||||
- member_id: janesmith
|
||||
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>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
Run <code>meshcore-hub collector seed</code> to import members.<br/>
|
||||
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -22,8 +22,63 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-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 (7 days) -->
|
||||
<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_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<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">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Node Count Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -55,22 +110,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Count Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
@@ -163,27 +202,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-4 mt-8 flex-wrap">
|
||||
<a href="/nodes" class="btn btn-primary">
|
||||
<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
|
||||
</a>
|
||||
<a href="/messages" 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="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>
|
||||
View Messages
|
||||
</a>
|
||||
<a href="/map" 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" />
|
||||
</svg>
|
||||
View Map
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
@@ -232,7 +250,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Advertisements chart
|
||||
// Advertisements chart (secondary color - pink/magenta)
|
||||
const advertCtx = document.getElementById('advertChart');
|
||||
if (advertCtx && advertData.data && advertData.data.length > 0) {
|
||||
new Chart(advertCtx, {
|
||||
@@ -242,8 +260,8 @@
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.7 0.15 250)',
|
||||
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
@@ -254,7 +272,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Messages chart
|
||||
// Messages chart (accent color - teal/cyan)
|
||||
const messageCtx = document.getElementById('messageChart');
|
||||
if (messageCtx && messageData.data && messageData.data.length > 0) {
|
||||
new Chart(messageCtx, {
|
||||
@@ -264,8 +282,8 @@
|
||||
datasets: [{
|
||||
label: 'Messages',
|
||||
data: messageData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.7 0.15 160)',
|
||||
backgroundColor: 'oklch(0.7 0.15 160 / 0.1)',
|
||||
borderColor: 'oklch(0.75 0.18 180)',
|
||||
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
@@ -276,7 +294,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Node count chart
|
||||
// Node count chart (primary color - purple/blue)
|
||||
const nodeCtx = document.getElementById('nodeChart');
|
||||
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
|
||||
new Chart(nodeCtx, {
|
||||
@@ -286,8 +304,8 @@
|
||||
datasets: [{
|
||||
label: 'Total Nodes',
|
||||
data: nodeData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.7 0.15 30)',
|
||||
backgroundColor: 'oklch(0.7 0.15 30 / 0.1)',
|
||||
borderColor: 'oklch(0.65 0.24 265)',
|
||||
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#node-map {
|
||||
height: 300px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
@@ -41,10 +57,18 @@
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
{% if node.adv_type %}
|
||||
<span class="badge badge-secondary">{{ node.adv_type }}</span>
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
@@ -61,32 +85,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if node.tags %}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Tags and Map Grid -->
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
|
||||
<!-- Tags -->
|
||||
{% if node.tags %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
|
||||
<div id="node-map" class="mb-2"></div>
|
||||
<div class="text-sm opacity-70">
|
||||
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,3 +255,67 @@
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
// Initialize map centered on the node's location
|
||||
const nodeLat = {{ ns_map.lat }};
|
||||
const nodeLon = {{ ns_map.lon }};
|
||||
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
|
||||
const nodeType = {{ (node.adv_type or '') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
|
||||
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(type) {
|
||||
const normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
const emoji = getNodeEmoji(nodeType);
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
|
||||
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Name or public key..." class="input input-bordered input-sm w-64" />
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"""Tests for dashboard API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node
|
||||
|
||||
|
||||
class TestDashboardStats:
|
||||
"""Tests for GET /dashboard/stats endpoint."""
|
||||
@@ -63,6 +69,21 @@ class TestDashboardHtml:
|
||||
class TestDashboardActivity:
|
||||
"""Tests for GET /dashboard/activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_advertisement(self, api_db_session):
|
||||
"""Create an advertisement from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
advert = Advertisement(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="TestNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
def test_get_activity_empty(self, client_no_auth):
|
||||
"""Test getting activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
@@ -91,8 +112,12 @@ class TestDashboardActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_activity_with_data(self, client_no_auth, sample_advertisement):
|
||||
"""Test getting activity with advertisement in database."""
|
||||
def test_get_activity_with_data(self, client_no_auth, past_advertisement):
|
||||
"""Test getting activity with advertisement in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -104,6 +129,21 @@ class TestDashboardActivity:
|
||||
class TestMessageActivity:
|
||||
"""Tests for GET /dashboard/message-activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_message(self, api_db_session):
|
||||
"""Create a message from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
message = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="abc123",
|
||||
text="Hello World",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
def test_get_message_activity_empty(self, client_no_auth):
|
||||
"""Test getting message activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
@@ -132,8 +172,12 @@ class TestMessageActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_message_activity_with_data(self, client_no_auth, sample_message):
|
||||
"""Test getting message activity with message in database."""
|
||||
def test_get_message_activity_with_data(self, client_no_auth, past_message):
|
||||
"""Test getting message activity with message in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -145,6 +189,23 @@ class TestMessageActivity:
|
||||
class TestNodeCountHistory:
|
||||
"""Tests for GET /dashboard/node-count endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_node(self, api_db_session):
|
||||
"""Create a node from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
node = Node(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="Test Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=yesterday,
|
||||
last_seen=yesterday,
|
||||
created_at=yesterday,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
def test_get_node_count_empty(self, client_no_auth):
|
||||
"""Test getting node count with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
@@ -173,8 +234,12 @@ class TestNodeCountHistory:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_node_count_with_data(self, client_no_auth, sample_node):
|
||||
"""Test getting node count with node in database."""
|
||||
def test_get_node_count_with_data(self, client_no_auth, past_node):
|
||||
"""Test getting node count with node in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
172
tests/test_collector/test_handlers/test_contacts.py
Normal file
172
tests/test_collector/test_handlers/test_contacts.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Tests for contact handler."""
|
||||
|
||||
import pytest
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from meshcore_hub.collector.handlers.contacts import handle_contact
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_manager(db_session):
|
||||
"""Create a mock database manager that uses the test session."""
|
||||
mock_db = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
try:
|
||||
yield db_session
|
||||
db_session.commit()
|
||||
except Exception:
|
||||
db_session.rollback()
|
||||
raise
|
||||
|
||||
mock_db.session_scope = session_scope
|
||||
return mock_db
|
||||
|
||||
|
||||
def test_handle_contact_creates_new_node(db_session, mock_db_manager):
|
||||
"""Test that contact handler creates new node with last_seen=None."""
|
||||
payload = {
|
||||
"public_key": "a" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 1, # chat
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify node was created
|
||||
node = db_session.query(Node).filter_by(public_key="a" * 64).first()
|
||||
assert node is not None
|
||||
assert node.name == "TestNode"
|
||||
assert node.adv_type == "chat"
|
||||
assert node.first_seen is not None
|
||||
assert node.last_seen is None # Should NOT be set by contact sync
|
||||
|
||||
|
||||
def test_handle_contact_updates_existing_node_name(db_session, mock_db_manager):
|
||||
"""Test that contact handler updates name but NOT last_seen."""
|
||||
# Create existing node with a last_seen timestamp
|
||||
last_seen_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="b" * 64,
|
||||
name="OldName",
|
||||
adv_type="chat",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=last_seen_time,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with new name
|
||||
payload = {
|
||||
"public_key": "b" * 64,
|
||||
"adv_name": "NewName",
|
||||
"type": 1,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify name was updated but last_seen was NOT
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="b" * 64).first()
|
||||
assert node.name == "NewName"
|
||||
# Compare timestamps without timezone (SQLite strips timezone info)
|
||||
assert node.last_seen is not None
|
||||
assert node.last_seen.replace(tzinfo=None) == last_seen_time.replace(tzinfo=None)
|
||||
|
||||
|
||||
def test_handle_contact_preserves_existing_adv_type(db_session, mock_db_manager):
|
||||
"""Test that contact handler doesn't overwrite existing adv_type."""
|
||||
# Create existing node with adv_type
|
||||
node = Node(
|
||||
public_key="c" * 64,
|
||||
name="TestNode",
|
||||
adv_type="repeater",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with different type
|
||||
payload = {
|
||||
"public_key": "c" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 1, # chat
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify adv_type was NOT changed
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="c" * 64).first()
|
||||
assert node.adv_type == "repeater" # Should preserve existing
|
||||
|
||||
|
||||
def test_handle_contact_sets_adv_type_if_missing(db_session, mock_db_manager):
|
||||
"""Test that contact handler sets adv_type if node doesn't have one."""
|
||||
# Create existing node without adv_type
|
||||
node = Node(
|
||||
public_key="d" * 64,
|
||||
name="TestNode",
|
||||
adv_type=None,
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with type
|
||||
payload = {
|
||||
"public_key": "d" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 2, # repeater
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify adv_type was set
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="d" * 64).first()
|
||||
assert node.adv_type == "repeater"
|
||||
|
||||
|
||||
def test_handle_contact_ignores_missing_public_key(db_session, mock_db_manager, caplog):
|
||||
"""Test that contact handler handles missing public_key gracefully."""
|
||||
payload = {
|
||||
"adv_name": "TestNode",
|
||||
"type": 1,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify warning was logged and no node created
|
||||
assert "missing public_key" in caplog.text
|
||||
count = db_session.query(Node).count()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_handle_contact_node_type_mapping(db_session, mock_db_manager):
|
||||
"""Test that numeric node types are correctly mapped to strings."""
|
||||
test_cases = [
|
||||
(0, "none"),
|
||||
(1, "chat"),
|
||||
(2, "repeater"),
|
||||
(3, "room"),
|
||||
]
|
||||
|
||||
for numeric_type, expected_string in test_cases:
|
||||
public_key = str(numeric_type) * 64
|
||||
payload = {
|
||||
"public_key": public_key,
|
||||
"adv_name": f"Node{numeric_type}",
|
||||
"type": numeric_type,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
node = db_session.query(Node).filter_by(public_key=public_key).first()
|
||||
assert node.adv_type == expected_string
|
||||
@@ -302,6 +302,7 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
|
||||
def mock_http_client_with_members() -> MockHttpClient:
|
||||
"""Create a mock HTTP client with members data."""
|
||||
client = MockHttpClient()
|
||||
# Mock the members API response (no nodes in the response anymore)
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/members",
|
||||
@@ -310,33 +311,23 @@ def mock_http_client_with_members() -> MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "member-1",
|
||||
"member_id": "alice",
|
||||
"name": "Alice",
|
||||
"callsign": "W1ABC",
|
||||
"role": "Admin",
|
||||
"description": None,
|
||||
"contact": "alice@example.com",
|
||||
"nodes": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"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",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "member-2",
|
||||
"member_id": "bob",
|
||||
"name": "Bob",
|
||||
"callsign": "W2XYZ",
|
||||
"role": "Member",
|
||||
"description": None,
|
||||
"contact": None,
|
||||
"nodes": [],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
@@ -346,6 +337,46 @@ def mock_http_client_with_members() -> MockHttpClient:
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
# Mock the nodes API response with has_tag filter
|
||||
# This will be called to get nodes with member_id tags
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Alice's Node",
|
||||
"adv_type": "chat",
|
||||
"flags": None,
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T00:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"key": "member_id",
|
||||
"value": "alice",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "Alice Chat",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ class TestNodeDetailPage:
|
||||
assert response.status_code == 200
|
||||
# Should display node details
|
||||
assert "Node One" in response.text
|
||||
assert "REPEATER" in response.text
|
||||
# Node type is shown as emoji with title attribute
|
||||
assert 'title="Repeater"' in response.text
|
||||
|
||||
def test_node_detail_displays_public_key(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
|
||||
Reference in New Issue
Block a user