mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd4f0b91dc | ||
|
|
a290db0491 | ||
|
|
92b0b883e6 | ||
|
|
9e621c0029 | ||
|
|
a251f3a09f | ||
|
|
0fdedfe5ba | ||
|
|
243a3e8521 | ||
|
|
b24a6f0894 | ||
|
|
57f51c741c | ||
|
|
65b8418af4 | ||
|
|
89ceee8741 | ||
|
|
64ec1a7135 | ||
|
|
3d632a94b1 | ||
|
|
fbd29ff78e | ||
|
|
86bff07f7d | ||
|
|
3abd5ce3ea | ||
|
|
0bf2086f16 | ||
|
|
40dc6647e9 | ||
|
|
f4e95a254e | ||
|
|
ba43be9e62 | ||
|
|
5b22ab29cf | ||
|
|
278d102064 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
@@ -2,11 +2,9 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -59,13 +57,13 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('0.0.0.dev0+g{0}', github.sha) }}
|
||||
BUILD_VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test Docker image
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
docker build -t meshcore-hub-test --build-arg SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0.dev0+g${{ github.sha }} -f Dockerfile .
|
||||
docker build -t meshcore-hub-test --build-arg BUILD_VERSION=${{ github.ref_name }} -f Dockerfile .
|
||||
docker run --rm meshcore-hub-test --version
|
||||
docker run --rm meshcore-hub-test --help
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -218,4 +218,3 @@ __marimo__/
|
||||
# MeshCore Hub specific
|
||||
*.db
|
||||
meshcore.db
|
||||
src/meshcore_hub/_version.py
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -624,6 +624,20 @@ On startup, the receiver performs these initialization steps:
|
||||
1. Set device clock to current Unix timestamp
|
||||
2. Send a local (non-flood) advertisement
|
||||
3. Start automatic message fetching
|
||||
4. Sync the device's contact database
|
||||
|
||||
### Contact Sync Behavior
|
||||
|
||||
The receiver syncs the device's contact database in two scenarios:
|
||||
|
||||
1. **Startup**: Initial sync when receiver starts
|
||||
2. **Advertisement Events**: Automatic sync triggered whenever an advertisement is received from the mesh
|
||||
|
||||
Since advertisements are typically received every ~20 minutes, contact sync happens automatically without manual intervention. Each contact from the device is published individually to MQTT:
|
||||
- Topic: `{prefix}/{device_public_key}/event/contact`
|
||||
- Payload: `{public_key, adv_name, type}`
|
||||
|
||||
This ensures the collector's database stays current with all nodes discovered on the mesh network.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -21,9 +21,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Build argument for version (set via CI or manually)
|
||||
ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0+docker
|
||||
|
||||
# Copy project files
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
@@ -31,9 +28,13 @@ COPY src/ ./src/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini ./
|
||||
|
||||
# Install the package with version from build arg
|
||||
RUN pip install --upgrade pip && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} pip install .
|
||||
# Build argument for version (set via CI or manually)
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
# Set version in _version.py and install the package
|
||||
RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/meshcore_hub/_version.py && \
|
||||
pip install --upgrade pip && \
|
||||
pip install .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime - Final production image
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
|
||||
@@ -318,7 +320,7 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
|
||||
#### Webhook Configuration
|
||||
|
||||
The collector can forward events to external HTTP endpoints:
|
||||
The collector can forward certain events to external HTTP endpoints:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
# IPNet Network Members
|
||||
members:
|
||||
- name: Louis
|
||||
callsign: Louis
|
||||
role: admin
|
||||
description: IPNet Founder
|
||||
nodes:
|
||||
# ip2-rep01
|
||||
- public_key: 2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1
|
||||
node_role: repeater
|
||||
# ip2-rep02
|
||||
- public_key: 8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90
|
||||
node_role: repeater
|
||||
# ip2-rep03
|
||||
- public_key: 5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b
|
||||
node_role: repeater
|
||||
# ip2-sol01
|
||||
- public_key: 87eb9487a1a4351e986e55627b2d09c4da61f94d080eaf4d7129caef89886e25
|
||||
node_role: repeater
|
||||
# personal chat node
|
||||
- public_key: c6e0d85528b4b5d7f53aa7dded2b7e0b9c8f8a5c00acfaad47476ef5f3c7dc47
|
||||
node_role: chat
|
||||
- name: Mark
|
||||
callsign: Mark
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: 22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe
|
||||
node_role: repeater
|
||||
- public_key: 9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8
|
||||
node_role: repeater
|
||||
- public_key: 2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb
|
||||
node_role: repeater
|
||||
- public_key: e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac
|
||||
node_role: repeater
|
||||
- public_key: d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a
|
||||
node_role: repeater
|
||||
- public_key: cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8
|
||||
node_role: repeater
|
||||
- public_key: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80
|
||||
node_role: repeater
|
||||
- public_key: 69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23
|
||||
node_role: repeater
|
||||
- name: CCZ
|
||||
callsign: CCZ
|
||||
role: member
|
||||
nodes:
|
||||
- public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459
|
||||
node_role: repeater
|
||||
- public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53
|
||||
node_role: repeater
|
||||
- public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac
|
||||
node_role: repeater
|
||||
description: IPNet Member
|
||||
- name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52
|
||||
node_role: repeater
|
||||
- public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2
|
||||
node_role: repeater
|
||||
- name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
nodes:
|
||||
- public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3
|
||||
node_role: repeater
|
||||
@@ -1,247 +0,0 @@
|
||||
# IPNet Network Node Tags
|
||||
# Uses YAML primitives: numbers, booleans, and strings are auto-detected
|
||||
|
||||
# IP2 Area Nodes
|
||||
2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1:
|
||||
friendly_name: IP2 Repeater 1
|
||||
node_id: ip2-rep01.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0357627
|
||||
lon: 1.132079
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 31
|
||||
role: infra
|
||||
|
||||
8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90:
|
||||
friendly_name: IP2 Repeater 2
|
||||
node_id: ip2-rep02.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0390682
|
||||
lon: 1.1304141
|
||||
location_description: Belstead Road
|
||||
hardware: Heltec V3
|
||||
antenna: McGill 6dBi Omni
|
||||
elevation: 44
|
||||
role: infra
|
||||
|
||||
5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b:
|
||||
friendly_name: IP2 Repeater 3
|
||||
node_id: ip2-rep03.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.046356
|
||||
lon: 1.134661
|
||||
location_description: Birkfield Drive
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 52
|
||||
role: infra
|
||||
|
||||
780d0939f90b22d3bd7cbedcaf4e8d468a12c01886ab24b8cfa11eab2f5516c5:
|
||||
friendly_name: IP2 Integration 1
|
||||
node_id: ip2-int01.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0354539
|
||||
lon: 1.1295338
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 25
|
||||
role: infra
|
||||
|
||||
30121dc60362c633c457ffa18f49b3e1d6823402c33709f32d7df70612250b96:
|
||||
friendly_name: MeshBot
|
||||
node_id: bot.ipnt.uk
|
||||
member_id: louis
|
||||
area: IP2
|
||||
lat: 52.0354539
|
||||
lon: 1.1295338
|
||||
location_description: Fountains Road
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 25
|
||||
role: infra
|
||||
|
||||
# IP3 Area Nodes
|
||||
9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8:
|
||||
friendly_name: IP3 Repeater 1
|
||||
node_id: ip3-rep01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.045803
|
||||
lon: 1.204416
|
||||
location_description: Brokehall
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 8.5dBi Omni
|
||||
elevation: 42
|
||||
role: infra
|
||||
|
||||
e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459:
|
||||
friendly_name: IP3 Repeater 2
|
||||
node_id: ip3-rep02.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.03297
|
||||
lon: 1.17543
|
||||
location_description: Morland Road Allotments
|
||||
hardware: Heltec T114
|
||||
antenna: Unknown
|
||||
elevation: 39
|
||||
role: infra
|
||||
|
||||
cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53:
|
||||
friendly_name: IP3 Repeater 3
|
||||
node_id: ip3-rep03.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.04499
|
||||
lon: 1.18149
|
||||
location_description: Hatfield Road
|
||||
hardware: Heltec V3
|
||||
antenna: Unknown
|
||||
elevation: 39
|
||||
role: infra
|
||||
|
||||
22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe:
|
||||
friendly_name: IP3 Integration 1
|
||||
node_id: ip3-int01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.045773
|
||||
lon: 1.212808
|
||||
location_description: Brokehall
|
||||
hardware: Heltec V3
|
||||
antenna: Generic 3dBi Whip
|
||||
elevation: 37
|
||||
role: infra
|
||||
|
||||
2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb:
|
||||
friendly_name: IP3 Repeater 4
|
||||
node_id: ip3-rep04.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.046383
|
||||
lon: 1.174542
|
||||
location_description: Holywells
|
||||
hardware: Sensecap Solar
|
||||
antenna: Paradar 6.5dbi Omni
|
||||
elevation: 21
|
||||
role: infra
|
||||
|
||||
e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac:
|
||||
friendly_name: IP3 Repeater 5
|
||||
node_id: ip3-rep05.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.05252
|
||||
lon: 1.17034
|
||||
location_description: Back Hamlet
|
||||
hardware: Heltec T114
|
||||
antenna: Paradar 6.5dBi Omni
|
||||
elevation: 38
|
||||
role: infra
|
||||
|
||||
20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac:
|
||||
friendly_name: IP3 Repeater 6
|
||||
node_id: ip3-rep06.ipnt.uk
|
||||
member_id: ccz
|
||||
area: IP3
|
||||
lat: 52.04893
|
||||
lon: 1.18965
|
||||
location_description: Dover Road
|
||||
hardware: Unknown
|
||||
antenna: Generic 5dBi Whip
|
||||
elevation: 38
|
||||
role: infra
|
||||
|
||||
69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23:
|
||||
friendly_name: IP3 Test Repeater 1
|
||||
node_id: ip3-tst01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
lat: 52.041869
|
||||
lon: 1.204789
|
||||
location_description: Brokehall
|
||||
hardware: Station G2
|
||||
antenna: McGill 10dBi Panel
|
||||
elevation: 37
|
||||
role: infra
|
||||
|
||||
ebb16e6c328b3f2fa5bc46a8c3efc8e9ad1960ee49a76dfa85abddbf7911e2ca:
|
||||
friendly_name: IP3 Integration 1
|
||||
node_id: ip3-int01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP3
|
||||
location_description: Morland Road Allotments
|
||||
role: infra
|
||||
|
||||
# IP4 Area Nodes
|
||||
c464e725906e956b0cc113f4eb3ae320db66209d0b7cf1924e258b0f86147cae:
|
||||
friendly_name: IP4 Repeater 1
|
||||
node_id: ip4-rep01.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP4
|
||||
lat: 52.052445
|
||||
lon: 1.156882
|
||||
location_description: Wine Rack
|
||||
hardware: Heltec T114
|
||||
antenna: Generic 5dbi Whip
|
||||
elevation: 50
|
||||
role: infra
|
||||
|
||||
cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8:
|
||||
friendly_name: IP4 Repeater 2
|
||||
node_id: ip4-rep02.ipnt.uk
|
||||
member_id: markab
|
||||
area: IP4
|
||||
lat: 52.06217
|
||||
lon: 1.18332
|
||||
location_description: Rushmere Road
|
||||
hardware: Heltec V3
|
||||
antenna: Paradar 5dbi Whip
|
||||
elevation: 35
|
||||
role: infra
|
||||
|
||||
8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3:
|
||||
friendly_name: IP4 Repeater 3
|
||||
node_id: ip4-rep03.ipnt.uk
|
||||
member_id: craig
|
||||
area: IP4
|
||||
lat: 52.058
|
||||
lon: 1.165
|
||||
location_description: IP4 Area
|
||||
hardware: Heltec v3
|
||||
antenna: Generic Whip
|
||||
elevation: 30
|
||||
role: infra
|
||||
|
||||
# IP8 Area Nodes
|
||||
bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52:
|
||||
friendly_name: IP8 Repeater 1
|
||||
node_id: rep01.ip8.ipnt.uk
|
||||
member_id: walshie86
|
||||
area: IP8
|
||||
lat: 52.033684
|
||||
lon: 1.118384
|
||||
location_description: Grove Hill
|
||||
hardware: Heltec V3
|
||||
antenna: McGill 3dBi Omni
|
||||
elevation: 13
|
||||
role: infra
|
||||
|
||||
9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2:
|
||||
friendly_name: IP8 Repeater 2
|
||||
node_id: rep02.ip8.ipnt.uk
|
||||
member_id: walshie86
|
||||
area: IP8
|
||||
lat: 52.035648
|
||||
lon: 1.073271
|
||||
location_description: Washbrook
|
||||
hardware: Sensecap Solar
|
||||
elevation: 13
|
||||
role: infra
|
||||
@@ -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
|
||||
|
||||
BIN
docs/images/web.png
Normal file
BIN
docs/images/web.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel", "setuptools-scm>=8.0"]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshcore-hub"
|
||||
dynamic = ["version"]
|
||||
version = "0.0.0"
|
||||
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
@@ -68,10 +68,6 @@ Documentation = "https://github.com/ipnet-mesh/meshcore-hub#readme"
|
||||
Repository = "https://github.com/ipnet-mesh/meshcore-hub"
|
||||
Issues = "https://github.com/ipnet-mesh/meshcore-hub/issues"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
version_file = "src/meshcore_hub/_version.py"
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks."""
|
||||
|
||||
from meshcore_hub._version import __version__, __version_tuple__
|
||||
from meshcore_hub._version import __version__
|
||||
|
||||
__all__ = ["__version__", "__version_tuple__"]
|
||||
__all__ = ["__version__"]
|
||||
|
||||
8
src/meshcore_hub/_version.py
Normal file
8
src/meshcore_hub/_version.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""MeshCore Hub version information.
|
||||
|
||||
This file contains the version string for the package.
|
||||
It can be overridden at build time by setting BUILD_VERSION environment variable.
|
||||
"""
|
||||
|
||||
__version__ = "dev"
|
||||
__all__ = ["__version__"]
|
||||
@@ -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
|
||||
@@ -19,12 +19,12 @@ from meshcore_hub.common.schemas.messages import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract friendly_name tag from a node's tags."""
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract name tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
if tag.key == "name":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
@@ -57,15 +57,15 @@ def _fetch_receivers_for_events(
|
||||
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
|
||||
|
||||
node_ids = [r.node_id for r in results]
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if node_ids:
|
||||
fn_query = (
|
||||
tag_query = (
|
||||
select(NodeTag.node_id, NodeTag.value)
|
||||
.where(NodeTag.node_id.in_(node_ids))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for node_id, value in session.execute(fn_query).all():
|
||||
friendly_names[node_id] = value
|
||||
for node_id, value in session.execute(tag_query).all():
|
||||
tag_names[node_id] = value
|
||||
|
||||
for row in results:
|
||||
if row.event_hash not in receivers_by_hash:
|
||||
@@ -76,7 +76,7 @@ def _fetch_receivers_for_events(
|
||||
node_id=row.node_id,
|
||||
public_key=row.public_key,
|
||||
name=row.name,
|
||||
friendly_name=friendly_names.get(row.node_id),
|
||||
tag_name=tag_names.get(row.node_id),
|
||||
snr=row.snr,
|
||||
received_at=row.received_at,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -173,11 +192,11 @@ async def list_advertisements(
|
||||
data = {
|
||||
"received_by": row.receiver_pk,
|
||||
"receiver_name": row.receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"public_key": adv.public_key,
|
||||
"name": adv.name,
|
||||
"node_name": row.source_name,
|
||||
"node_friendly_name": _get_friendly_name(source_node),
|
||||
"node_tag_name": _get_tag_name(source_node),
|
||||
"adv_type": adv.adv_type or row.source_adv_type,
|
||||
"flags": adv.flags,
|
||||
"received_at": adv.received_at,
|
||||
@@ -255,11 +274,11 @@ async def get_advertisement(
|
||||
data = {
|
||||
"received_by": result.receiver_pk,
|
||||
"receiver_name": result.receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"public_key": adv.public_key,
|
||||
"name": adv.name,
|
||||
"node_name": result.source_name,
|
||||
"node_friendly_name": _get_friendly_name(source_node),
|
||||
"node_tag_name": _get_tag_name(source_node),
|
||||
"adv_type": adv.adv_type or result.source_adv_type,
|
||||
"flags": adv.flags,
|
||||
"received_at": adv.received_at,
|
||||
|
||||
@@ -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(
|
||||
@@ -82,11 +103,11 @@ async def get_stats(
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get node names, adv_types, and friendly_name tags for the advertised nodes
|
||||
# Get node names, adv_types, and name tags for the advertised nodes
|
||||
ad_public_keys = [ad.public_key for ad in recent_ads]
|
||||
node_names: dict[str, str] = {}
|
||||
node_adv_types: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if ad_public_keys:
|
||||
# Get node names and adv_types from Node table
|
||||
node_query = select(Node.public_key, Node.name, Node.adv_type).where(
|
||||
@@ -98,21 +119,21 @@ async def get_stats(
|
||||
if adv_type:
|
||||
node_adv_types[public_key] = adv_type
|
||||
|
||||
# Get friendly_name tags
|
||||
friendly_name_query = (
|
||||
# Get name tags
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.in_(ad_public_keys))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
tag_names[public_key] = value
|
||||
|
||||
recent_advertisements = [
|
||||
RecentAdvertisement(
|
||||
public_key=ad.public_key,
|
||||
name=ad.name or node_names.get(ad.public_key),
|
||||
friendly_name=friendly_names.get(ad.public_key),
|
||||
tag_name=tag_names.get(ad.public_key),
|
||||
adv_type=ad.adv_type or node_adv_types.get(ad.public_key),
|
||||
received_at=ad.received_at,
|
||||
)
|
||||
@@ -146,7 +167,7 @@ async def get_stats(
|
||||
# Look up sender names for these messages
|
||||
msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix]
|
||||
msg_sender_names: dict[str, str] = {}
|
||||
msg_friendly_names: dict[str, str] = {}
|
||||
msg_tag_names: dict[str, str] = {}
|
||||
if msg_prefixes:
|
||||
for prefix in set(msg_prefixes):
|
||||
sender_node_query = select(Node.public_key, Node.name).where(
|
||||
@@ -156,14 +177,14 @@ async def get_stats(
|
||||
if name:
|
||||
msg_sender_names[public_key[:12]] = name
|
||||
|
||||
sender_friendly_query = (
|
||||
sender_tag_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(sender_friendly_query).all():
|
||||
msg_friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(sender_tag_query).all():
|
||||
msg_tag_names[public_key[:12]] = value
|
||||
|
||||
channel_messages[int(channel_idx)] = [
|
||||
ChannelMessage(
|
||||
@@ -171,8 +192,8 @@ async def get_stats(
|
||||
sender_name=(
|
||||
msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
sender_friendly_name=(
|
||||
msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
sender_tag_name=(
|
||||
msg_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
pubkey_prefix=m.pubkey_prefix,
|
||||
received_at=m.received_at,
|
||||
@@ -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"),
|
||||
friendly_name=info.get("friendly_name"),
|
||||
)
|
||||
)
|
||||
return enriched_nodes
|
||||
|
||||
|
||||
def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead:
|
||||
"""Convert a Member model to MemberRead with enriched node data."""
|
||||
return MemberRead(
|
||||
id=member.id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
description=member.description,
|
||||
contact=member.contact,
|
||||
nodes=_enrich_member_nodes(member, node_info),
|
||||
created_at=member.created_at,
|
||||
updated_at=member.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MemberList)
|
||||
async def list_members(
|
||||
_: RequireRead,
|
||||
@@ -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:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return MemberList(
|
||||
items=[_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:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return _member_to_read(member, node_info)
|
||||
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)
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead, Recei
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract friendly_name tag from a node's tags."""
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract name tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
if tag.key == "name":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
@@ -64,17 +64,17 @@ def _fetch_receivers_for_events(
|
||||
# Group by event_hash
|
||||
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
|
||||
|
||||
# Get friendly names for receiver nodes
|
||||
# Get tag names for receiver nodes
|
||||
node_ids = [r.node_id for r in results]
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if node_ids:
|
||||
fn_query = (
|
||||
tag_query = (
|
||||
select(NodeTag.node_id, NodeTag.value)
|
||||
.where(NodeTag.node_id.in_(node_ids))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for node_id, value in session.execute(fn_query).all():
|
||||
friendly_names[node_id] = value
|
||||
for node_id, value in session.execute(tag_query).all():
|
||||
tag_names[node_id] = value
|
||||
|
||||
for row in results:
|
||||
if row.event_hash not in receivers_by_hash:
|
||||
@@ -85,7 +85,7 @@ def _fetch_receivers_for_events(
|
||||
node_id=row.node_id,
|
||||
public_key=row.public_key,
|
||||
name=row.name,
|
||||
friendly_name=friendly_names.get(row.node_id),
|
||||
tag_name=tag_names.get(row.node_id),
|
||||
snr=row.snr,
|
||||
received_at=row.received_at,
|
||||
)
|
||||
@@ -153,10 +153,10 @@ async def list_messages(
|
||||
# Execute
|
||||
results = session.execute(query).all()
|
||||
|
||||
# Look up sender names and friendly_names for senders with pubkey_prefix
|
||||
# Look up sender names and tag names for senders with pubkey_prefix
|
||||
pubkey_prefixes = [r[0].pubkey_prefix for r in results if r[0].pubkey_prefix]
|
||||
sender_names: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
sender_tag_names: dict[str, str] = {}
|
||||
if pubkey_prefixes:
|
||||
# Find nodes whose public_key starts with any of these prefixes
|
||||
for prefix in set(pubkey_prefixes):
|
||||
@@ -168,15 +168,15 @@ async def list_messages(
|
||||
if name:
|
||||
sender_names[public_key[:12]] = name
|
||||
|
||||
# Get friendly_name tag
|
||||
friendly_name_query = (
|
||||
# Get name tag
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
sender_tag_names[public_key[:12]] = value
|
||||
|
||||
# Collect receiver node IDs to fetch tags
|
||||
receiver_ids = set()
|
||||
@@ -214,14 +214,14 @@ async def list_messages(
|
||||
"receiver_node_id": m.receiver_node_id,
|
||||
"received_by": receiver_pk,
|
||||
"receiver_name": receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"message_type": m.message_type,
|
||||
"pubkey_prefix": m.pubkey_prefix,
|
||||
"sender_name": (
|
||||
sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"sender_friendly_name": (
|
||||
friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
"sender_tag_name": (
|
||||
sender_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"channel_idx": m.channel_idx,
|
||||
"text": m.text,
|
||||
|
||||
@@ -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,
|
||||
@@ -383,8 +358,11 @@ def _run_seed_import(
|
||||
file_path=str(node_tags_file),
|
||||
db=db,
|
||||
create_nodes=create_nodes,
|
||||
clear_existing=True,
|
||||
)
|
||||
if verbose:
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Deleted {stats['deleted']} existing tags")
|
||||
click.echo(
|
||||
f" Tags: {stats['created']} created, {stats['updated']} updated"
|
||||
)
|
||||
@@ -428,16 +406,24 @@ def _run_seed_import(
|
||||
default=False,
|
||||
help="Skip tags for nodes that don't exist (default: create nodes)",
|
||||
)
|
||||
@click.option(
|
||||
"--clear-existing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Delete all existing tags before importing",
|
||||
)
|
||||
@click.pass_context
|
||||
def import_tags_cmd(
|
||||
ctx: click.Context,
|
||||
file: str | None,
|
||||
no_create_nodes: bool,
|
||||
clear_existing: bool,
|
||||
) -> None:
|
||||
"""Import node tags from a YAML file.
|
||||
|
||||
Reads a YAML file containing tag definitions and upserts them
|
||||
into the database. Existing tags are updated, new tags are created.
|
||||
into the database. By default, existing tags are updated and new tags are created.
|
||||
Use --clear-existing to delete all tags before importing.
|
||||
|
||||
FILE is the path to the YAML file containing tags.
|
||||
If not provided, defaults to {SEED_HOME}/node_tags.yaml.
|
||||
@@ -492,11 +478,14 @@ def import_tags_cmd(
|
||||
file_path=tags_file,
|
||||
db=db,
|
||||
create_nodes=not no_create_nodes,
|
||||
clear_existing=clear_existing,
|
||||
)
|
||||
|
||||
# Report results
|
||||
click.echo("")
|
||||
click.echo("Import complete:")
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Tags deleted: {stats['deleted']}")
|
||||
click.echo(f" Total tags in file: {stats['total']}")
|
||||
click.echo(f" Tags created: {stats['created']}")
|
||||
click.echo(f" Tags updated: {stats['updated']}")
|
||||
@@ -674,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)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
@@ -151,16 +151,19 @@ def import_tags(
|
||||
file_path: str | Path,
|
||||
db: DatabaseManager,
|
||||
create_nodes: bool = True,
|
||||
clear_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import tags from a YAML file into the database.
|
||||
|
||||
Performs upsert operations - existing tags are updated, new tags are created.
|
||||
Optionally clears all existing tags before import.
|
||||
|
||||
Args:
|
||||
file_path: Path to the tags YAML file
|
||||
db: Database manager instance
|
||||
create_nodes: If True, create nodes that don't exist. If False, skip tags
|
||||
for non-existent nodes.
|
||||
clear_existing: If True, delete all existing tags before importing.
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics:
|
||||
@@ -169,6 +172,7 @@ def import_tags(
|
||||
- updated: Number of existing tags updated
|
||||
- skipped: Number of tags skipped (node not found and create_nodes=False)
|
||||
- nodes_created: Number of new nodes created
|
||||
- deleted: Number of existing tags deleted (if clear_existing=True)
|
||||
- errors: List of error messages
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
@@ -177,6 +181,7 @@ def import_tags(
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"nodes_created": 0,
|
||||
"deleted": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@@ -194,6 +199,15 @@ def import_tags(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Clear all existing tags if requested
|
||||
if clear_existing:
|
||||
delete_count = (
|
||||
session.execute(select(func.count()).select_from(NodeTag)).scalar() or 0
|
||||
)
|
||||
session.execute(delete(NodeTag))
|
||||
stats["deleted"] = delete_count
|
||||
logger.info(f"Deleted {delete_count} existing tags")
|
||||
|
||||
# Cache nodes by public_key to reduce queries
|
||||
node_cache: dict[str, Node] = {}
|
||||
|
||||
@@ -232,24 +246,8 @@ def import_tags(
|
||||
tag_value = tag_data.get("value")
|
||||
tag_type = tag_data.get("type", "string")
|
||||
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
if clear_existing:
|
||||
# When clearing, always create new tags
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
@@ -262,6 +260,39 @@ def import_tags(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(
|
||||
tag_query
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
value=tag_value,
|
||||
value_type=tag_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
stats["created"] += 1
|
||||
logger.debug(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing tag {tag_key} for {public_key[:12]}...: {e}"
|
||||
|
||||
@@ -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,46 +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"
|
||||
)
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node's friendly 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,
|
||||
@@ -71,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,
|
||||
@@ -105,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")
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ class ReceiverInfo(BaseModel):
|
||||
node_id: str = Field(..., description="Receiver node UUID")
|
||||
public_key: str = Field(..., description="Receiver node public key")
|
||||
name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
)
|
||||
tag_name: Optional[str] = Field(default=None, description="Receiver name from tags")
|
||||
snr: Optional[float] = Field(
|
||||
default=None, description="Signal-to-noise ratio at this receiver"
|
||||
)
|
||||
@@ -31,8 +29,8 @@ class MessageRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
message_type: str = Field(..., description="Message type (contact, channel)")
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
@@ -41,8 +39,8 @@ class MessageRead(BaseModel):
|
||||
sender_name: Optional[str] = Field(
|
||||
default=None, description="Sender's advertised node name"
|
||||
)
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender's friendly name from node tags"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender's name from node tags"
|
||||
)
|
||||
channel_idx: Optional[int] = Field(default=None, description="Channel index")
|
||||
text: str = Field(..., description="Message content")
|
||||
@@ -110,16 +108,16 @@ class AdvertisementRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
public_key: str = Field(..., description="Advertised public key")
|
||||
name: Optional[str] = Field(default=None, description="Advertised name")
|
||||
node_name: Optional[str] = Field(
|
||||
default=None, description="Node name from nodes table"
|
||||
)
|
||||
node_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node friendly name from tags"
|
||||
node_tag_name: Optional[str] = Field(
|
||||
default=None, description="Node name from tags"
|
||||
)
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
@@ -215,7 +213,7 @@ class RecentAdvertisement(BaseModel):
|
||||
|
||||
public_key: str = Field(..., description="Node public key")
|
||||
name: Optional[str] = Field(default=None, description="Node name")
|
||||
friendly_name: Optional[str] = Field(default=None, description="Friendly name tag")
|
||||
tag_name: Optional[str] = Field(default=None, description="Name tag")
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
|
||||
@@ -225,8 +223,8 @@ class ChannelMessage(BaseModel):
|
||||
|
||||
text: str = Field(..., description="Message text")
|
||||
sender_name: Optional[str] = Field(default=None, description="Sender name")
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender friendly name"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender name from tags"
|
||||
)
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
default=None, description="Sender public key prefix"
|
||||
@@ -241,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,
|
||||
|
||||
@@ -193,11 +193,24 @@ class BaseMeshCoreDevice(ABC):
|
||||
|
||||
Triggers a CONTACTS event with all stored contacts from the device.
|
||||
|
||||
Note: This should only be called before the event loop is running.
|
||||
|
||||
Returns:
|
||||
True if request was sent successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
@@ -567,7 +580,12 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
return False
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from device contact database."""
|
||||
"""Fetch contacts from device contact database.
|
||||
|
||||
Note: This method should only be called before the event loop is running
|
||||
(e.g., during initialization). For calling during event processing,
|
||||
use schedule_get_contacts() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -584,6 +602,31 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
logger.error(f"Failed to get contacts: {e}")
|
||||
return False
|
||||
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
The request is scheduled as a task on the event loop.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled, False if device not connected
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _get_contacts() -> None:
|
||||
await self._mc.commands.get_contacts()
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_get_contacts(), self._loop)
|
||||
logger.info("Scheduled contact sync request")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule get contacts: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -292,7 +292,10 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
return True
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from mock device contact database."""
|
||||
"""Fetch contacts from mock device contact database.
|
||||
|
||||
Note: This should only be called before the event loop is running.
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -318,6 +321,14 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
threading.Thread(target=send_contacts, daemon=True).start()
|
||||
return True
|
||||
|
||||
def schedule_get_contacts(self) -> bool:
|
||||
"""Schedule a get_contacts request.
|
||||
|
||||
For the mock device, this is the same as get_contacts() since we
|
||||
don't have a real async event loop. The contacts are sent via a thread.
|
||||
"""
|
||||
return self.get_contacts()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -144,9 +144,24 @@ class Receiver:
|
||||
|
||||
logger.debug(f"Published {event_name} event to MQTT")
|
||||
|
||||
# Trigger contact sync on advertisements
|
||||
if event_type == EventType.ADVERTISEMENT:
|
||||
self._sync_contacts()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish event to MQTT: {e}")
|
||||
|
||||
def _sync_contacts(self) -> None:
|
||||
"""Request contact sync from device.
|
||||
|
||||
Called when advertisements are received to ensure contact database
|
||||
stays current with all nodes on the mesh.
|
||||
"""
|
||||
logger.info("Advertisement received, triggering contact sync")
|
||||
success = self.device.schedule_get_contacts()
|
||||
if not success:
|
||||
logger.warning("Contact sync request failed")
|
||||
|
||||
def _publish_contacts(self, payload: dict[str, Any]) -> None:
|
||||
"""Publish each contact as a separate MQTT message.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
63
src/meshcore_hub/web/static/js/utils.js
Normal file
63
src/meshcore_hub/web/static/js/utils.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* MeshCore Hub - Common JavaScript Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object
|
||||
* @returns {string} Relative time string, or empty string if invalid
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate all elements with data-timestamp attribute with relative time
|
||||
*/
|
||||
function populateRelativeTimestamps() {
|
||||
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
|
||||
const timestamp = el.dataset.timestamp;
|
||||
if (timestamp) {
|
||||
el.textContent = formatRelativeTime(timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate receiver tooltip elements with name and relative time
|
||||
*/
|
||||
function populateReceiverTooltips() {
|
||||
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
const timestamp = el.dataset.timestamp;
|
||||
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
|
||||
|
||||
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
|
||||
let tooltip = name;
|
||||
if (relTime) {
|
||||
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
|
||||
}
|
||||
el.title = tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRelativeTimestamps();
|
||||
populateReceiverTooltips();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -39,82 +39,46 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in advertisements %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
{% if ad.node_friendly_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_friendly_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ ad.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in ad.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif ad.receivers and ad.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ ad.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if ad.receivers[0].friendly_name or ad.receivers[0].name %}
|
||||
<div class="font-medium">{{ ad.receivers[0].friendly_name or ad.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
|
||||
{% if ad.receiver_friendly_name or ad.receiver_name %}
|
||||
<div class="font-medium">{{ ad.receiver_friendly_name or ad.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -126,7 +90,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 +99,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 %}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="badge badge-outline badge-sm">v{{ version }}</div>
|
||||
<div class="badge badge-outline badge-sm">{{ version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,13 +114,16 @@
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> v{{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Common utilities -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,12 +52,6 @@
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Infrastructure Only</span>
|
||||
<input type="checkbox" id="filter-infra" class="checkbox checkbox-sm checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +82,10 @@
|
||||
<span class="text-lg">📍</span>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg" style="filter: drop-shadow(0 0 4px gold);">📡</span>
|
||||
<span>Infrastructure (gold glow)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags. Infrastructure nodes are tagged with <code>role: infra</code>.</p>
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -120,23 +110,7 @@
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Format relative time (e.g., "2m", "1h", "2d")
|
||||
function formatRelativeTime(lastSeenStr) {
|
||||
if (!lastSeenStr) return null;
|
||||
|
||||
const lastSeen = new Date(lastSeenStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - lastSeen;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
// formatRelativeTime is provided by /static/js/utils.js
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(node) {
|
||||
@@ -159,14 +133,13 @@
|
||||
// Create marker icon for a node
|
||||
function createNodeIcon(node) {
|
||||
const emoji = getNodeEmoji(node);
|
||||
const infraGlow = node.is_infra ? 'filter: drop-shadow(0 0 4px gold);' : '';
|
||||
const displayName = node.name || '';
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="display: flex; align-items: center; gap: 2px;">
|
||||
<span style="font-size: 24px; ${infraGlow} text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
|
||||
</div>`,
|
||||
iconSize: [82, 28],
|
||||
@@ -186,8 +159,7 @@
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
|
||||
}
|
||||
|
||||
const emoji = getNodeEmoji(node);
|
||||
@@ -219,16 +191,12 @@
|
||||
function applyFiltersCore() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const ownerFilter = document.getElementById('filter-owner').value;
|
||||
const infraOnly = document.getElementById('filter-infra').checked;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// Infrastructure filter
|
||||
if (infraOnly && !node.is_infra) return false;
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter) {
|
||||
if (!node.owner || node.owner.public_key !== ownerFilter) return false;
|
||||
@@ -314,14 +282,12 @@
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-owner').value = '';
|
||||
document.getElementById('filter-infra').checked = false;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-owner').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-infra').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -57,19 +57,15 @@
|
||||
<th>Time</th>
|
||||
<th>From/Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Receiver</th>
|
||||
<th>SNR</th>
|
||||
<th class="text-center">SNR</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr class="hover align-top">
|
||||
<td>
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="badge badge-info badge-sm">Channel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success badge-sm">Direct</span>
|
||||
{% endif %}
|
||||
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
@@ -78,55 +74,14 @@
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="font-mono">CH{{ msg.channel_idx }}</span>
|
||||
{% else %}
|
||||
{% if msg.sender_friendly_name or msg.sender_name %}
|
||||
<span class="font-medium">{{ msg.sender_friendly_name or msg.sender_name }}</span>
|
||||
{% if msg.sender_tag_name or msg.sender_name %}
|
||||
<span class="font-medium">{{ msg.sender_tag_name or msg.sender_name }}</span>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ msg.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in msg.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
<span class="flex-1">{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}</span>
|
||||
{% if recv.snr is not none %}
|
||||
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(recv.snr) }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif msg.receivers and msg.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ msg.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if msg.receivers[0].friendly_name or msg.receivers[0].name %}
|
||||
<div class="font-medium">{{ msg.receivers[0].friendly_name or msg.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
|
||||
{% if msg.receiver_friendly_name or msg.receiver_name %}
|
||||
<div class="font-medium">{{ msg.receiver_friendly_name or msg.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center whitespace-nowrap">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
|
||||
@@ -134,6 +89,19 @@
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
@@ -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,19 +2,35 @@
|
||||
|
||||
{% 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>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
{% if node %}
|
||||
{% set ns = namespace(friendly_name=none) %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'friendly_name' %}
|
||||
{% set ns.friendly_name = tag.value %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li>{{ ns.friendly_name or node.name or public_key[:12] + '...' }}</li>
|
||||
<li>{{ ns.tag_name or node.name or public_key[:12] + '...' }}</li>
|
||||
{% else %}
|
||||
<li>Not Found</li>
|
||||
{% endif %}
|
||||
@@ -31,20 +47,28 @@
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
{% set ns = namespace(friendly_name=none) %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'friendly_name' %}
|
||||
{% set ns.friendly_name = tag.value %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Node Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{{ ns.friendly_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>
|
||||
|
||||
@@ -125,8 +172,8 @@
|
||||
<td>
|
||||
{% if adv.received_by %}
|
||||
<a href="/nodes/{{ adv.received_by }}" class="link link-hover">
|
||||
{% if adv.receiver_friendly_name or adv.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ adv.receiver_friendly_name or adv.receiver_name }}</div>
|
||||
{% if adv.receiver_tag_name or adv.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ adv.receiver_tag_name or adv.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ adv.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ adv.received_by[:16] }}...</span>
|
||||
@@ -175,8 +222,8 @@
|
||||
<td>
|
||||
{% if tel.received_by %}
|
||||
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
|
||||
{% if tel.receiver_friendly_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_friendly_name or tel.receiver_name }}</div>
|
||||
{% if tel.receiver_tag_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
|
||||
@@ -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">
|
||||
@@ -50,43 +50,32 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(friendly_name=none) %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'friendly_name' %}
|
||||
{% set ns.friendly_name = tag.value %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
|
||||
{% if ns.friendly_name or node.name %}
|
||||
<div class="font-medium">{{ ns.friendly_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if node.adv_type and node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif node.adv_type %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
@@ -111,7 +100,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -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
|
||||
@@ -390,3 +390,64 @@ class TestImportTags:
|
||||
assert tag_dict["is_disabled"].value_type == "boolean"
|
||||
|
||||
Path(f.name).unlink()
|
||||
|
||||
def test_import_with_clear_existing(self, db_manager):
|
||||
"""Test that clear_existing deletes all tags before importing."""
|
||||
# Create initial tags
|
||||
initial_data = {
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
|
||||
"old_tag": "old_value",
|
||||
"shared_tag": "old_value",
|
||||
},
|
||||
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": {
|
||||
"another_old_tag": "value",
|
||||
},
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(initial_data, f)
|
||||
f.flush()
|
||||
initial_file = f.name
|
||||
|
||||
stats1 = import_tags(initial_file, db_manager, create_nodes=True)
|
||||
assert stats1["created"] == 3
|
||||
assert stats1["deleted"] == 0
|
||||
|
||||
# Verify initial tags exist
|
||||
with db_manager.session_scope() as session:
|
||||
tags = session.execute(select(NodeTag)).scalars().all()
|
||||
assert len(tags) == 3
|
||||
|
||||
# Import new tags with clear_existing=True
|
||||
new_data = {
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
|
||||
"new_tag": "new_value",
|
||||
"shared_tag": "new_value",
|
||||
}
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(new_data, f)
|
||||
f.flush()
|
||||
new_file = f.name
|
||||
|
||||
stats2 = import_tags(
|
||||
new_file, db_manager, create_nodes=True, clear_existing=True
|
||||
)
|
||||
assert stats2["deleted"] == 3 # All 3 old tags deleted
|
||||
assert stats2["created"] == 2 # 2 new tags created
|
||||
assert stats2["updated"] == 0 # No updates when clearing
|
||||
|
||||
# Verify only new tags exist
|
||||
with db_manager.session_scope() as session:
|
||||
tags = session.execute(select(NodeTag)).scalars().all()
|
||||
tag_dict = {t.key: t for t in tags}
|
||||
assert len(tags) == 2
|
||||
assert "new_tag" in tag_dict
|
||||
assert "shared_tag" in tag_dict
|
||||
assert tag_dict["shared_tag"].value == "new_value"
|
||||
assert "old_tag" not in tag_dict
|
||||
assert "another_old_tag" not in tag_dict
|
||||
|
||||
Path(initial_file).unlink()
|
||||
Path(new_file).unlink()
|
||||
|
||||
@@ -62,6 +62,56 @@ class TestReceiver:
|
||||
# Verify MQTT publish was called
|
||||
mock_mqtt_client.publish_event.assert_called()
|
||||
|
||||
def test_receiver_syncs_contacts_on_advertisement(
|
||||
self, receiver, mock_device, mock_mqtt_client
|
||||
):
|
||||
"""Test that receiver syncs contacts when advertisement is received."""
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
receiver.start()
|
||||
|
||||
# Patch schedule_get_contacts to track calls
|
||||
with patch.object(
|
||||
mock_device, "schedule_get_contacts", return_value=True
|
||||
) as mock_get:
|
||||
# Inject an advertisement event
|
||||
mock_device.inject_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{"pubkey_prefix": "b" * 64, "adv_name": "TestNode", "type": 1},
|
||||
)
|
||||
|
||||
# Allow time for event processing
|
||||
time.sleep(0.1)
|
||||
|
||||
# Verify schedule_get_contacts was called
|
||||
mock_get.assert_called()
|
||||
|
||||
def test_receiver_handles_contact_sync_failure(
|
||||
self, receiver, mock_device, mock_mqtt_client
|
||||
):
|
||||
"""Test that receiver handles contact sync failures gracefully."""
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
receiver.start()
|
||||
|
||||
# Patch schedule_get_contacts to return False (failure)
|
||||
with patch.object(
|
||||
mock_device, "schedule_get_contacts", return_value=False
|
||||
) as mock_get:
|
||||
# Should not raise exception even if sync fails
|
||||
mock_device.inject_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{"pubkey_prefix": "c" * 64, "adv_name": "FailNode", "type": 1},
|
||||
)
|
||||
|
||||
# Allow time for event processing
|
||||
time.sleep(0.1)
|
||||
|
||||
# Verify it was attempted
|
||||
mock_get.assert_called()
|
||||
|
||||
|
||||
class TestCreateReceiver:
|
||||
"""Tests for create_receiver factory function."""
|
||||
|
||||
@@ -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