Compare commits

..

35 Commits

Author SHA1 Message Date
Louis King a290db0491 Updated chart stats 2025-12-08 19:37:45 +00:00
Louis King 92b0b883e6 More website improvements 2025-12-08 17:07:39 +00:00
Louis King 9e621c0029 Fixed test 2025-12-08 16:42:13 +00:00
Louis King a251f3a09f Added map to node detail page, made title consistent with emoji 2025-12-08 16:37:53 +00:00
Louis King 0fdedfe5ba Tidied Advert/Node search 2025-12-08 16:22:08 +00:00
Louis King 243a3e8521 Added truncate CLI command 2025-12-08 15:54:32 +00:00
JingleManSweep b24a6f0894 Merge pull request #54 from ipnet-mesh/feature/more-filters
Fixed Member model
2025-12-08 15:15:04 +00:00
Louis King 57f51c741c Fixed Member model 2025-12-08 15:13:24 +00:00
Louis King 65b8418af4 Fixed last seen issue 2025-12-08 00:15:25 +00:00
JingleManSweep 89ceee8741 Merge pull request #51 from ipnet-mesh/feat/sync-receiver-contacts-on-advert
Receiver nodes now sync contacts to MQTT on every advert received
2025-12-07 23:36:11 +00:00
Louis King 64ec1a7135 Receiver nodes now sync contacts to MQTT on every advert received 2025-12-07 23:34:33 +00:00
JingleManSweep 3d632a94b1 Merge pull request #50 from ipnet-mesh/feat/remove-friendly-name
Removed friendly name support and tidied tags
2025-12-07 23:03:39 +00:00
Louis King fbd29ff78e Removed friendly name support and tidied tags 2025-12-07 23:02:19 +00:00
Louis King 86bff07f7d Removed contrib 2025-12-07 22:22:32 +00:00
Louis King 3abd5ce3ea Updates 2025-12-07 22:18:16 +00:00
Louis King 0bf2086f16 Added screenshot 2025-12-07 22:05:34 +00:00
Louis King 40dc6647e9 Updates 2025-12-07 22:02:42 +00:00
Louis King f4e95a254e Fixes 2025-12-07 22:00:46 +00:00
Louis King ba43be9e62 Fixes 2025-12-07 21:58:42 +00:00
JingleManSweep 5b22ab29cf Merge pull request #49 from ipnet-mesh/fix/version-display
Fixed version display
2025-12-07 21:56:26 +00:00
Louis King 278d102064 Fixed version display 2025-12-07 21:55:10 +00:00
JingleManSweep f0cee14bd8 Merge pull request #48 from ipnet-mesh/feature/mqtt-tls
Added support for MQTT TLS
2025-12-07 21:16:13 +00:00
Louis King 5ff8d16bcb Added support for MQTT TLS 2025-12-07 21:15:05 +00:00
JingleManSweep e8a60d4869 Merge pull request #47 from ipnet-mesh/feature/node-cleanup
Added Node/Data cleanup
2025-12-07 20:50:09 +00:00
Louis King 84b8614e29 Updates 2025-12-06 21:42:33 +00:00
Louis King 3bc47a33bc Added data retention and node cleanup 2025-12-06 21:27:19 +00:00
Louis King 3ae8ecbd70 Updates 2025-12-06 20:46:31 +00:00
JingleManSweep 38164380af Merge pull request #41 from ipnet-mesh/claude/issue-37-20251206-1854
feat: Add MESHCORE_DEVICE_NAME config to set node name on startup
2025-12-06 19:33:32 +00:00
claude[bot] dc3c771c76 docs: Document MESHCORE_DEVICE_NAME configuration option
Add documentation for the new MESHCORE_DEVICE_NAME environment variable
that was introduced in this PR. Updates include:

- Added to .env.example with description
- Added to Interface Settings table in README.md
- Added to CLI Reference examples in README.md
- Added to Interface configuration table in PLAN.md

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-authored-by: JingleManSweep <jinglemansweep@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 19:07:57 +00:00
claude[bot] deb307c6ae feat: Add MESHCORE_DEVICE_NAME config to set node name on startup
- Add meshcore_device_name field to InterfaceSettings
- Implement set_name() method in device interface (real and mock)
- Update receiver to set device name during initialization if configured
- Add --device-name CLI option with MESHCORE_DEVICE_NAME env var support
- Device name is set after time sync and before advertisement broadcast

Fixes #37

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: JingleManSweep <jinglemansweep@users.noreply.github.com>
2025-12-06 19:00:56 +00:00
JingleManSweep b8c8284643 Merge pull request #39 from ipnet-mesh/claude/issue-38-20251206-1840
Send flood advertisement on receiver startup
2025-12-06 18:48:50 +00:00
JingleManSweep d310a119ed Update src/meshcore_hub/interface/receiver.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-06 18:48:03 +00:00
claude[bot] 2b307679c9 Send flood advertisement on receiver startup
Changed the startup advertisement from flood=False to flood=True
so that the device name is broadcast to the mesh network.

Fixes #38

Co-authored-by: JingleManSweep <jinglemansweep@users.noreply.github.com>
2025-12-06 18:42:53 +00:00
Louis King 6f7521951f Updates 2025-12-06 18:29:12 +00:00
Louis King ab498292b2 Updated README with upgrading instructions 2025-12-06 17:31:10 +00:00
67 changed files with 2768 additions and 988 deletions
+41
View File
@@ -52,6 +52,11 @@ MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_PREFIX=meshcore
# Enable TLS/SSL for MQTT connection (default: false)
# When enabled, uses TLS with system CA certificates (e.g., for Let's Encrypt)
# Set to true for secure MQTT connections (port 8883)
MQTT_TLS=false
# External port mappings for local MQTT broker (--profile mqtt only)
MQTT_EXTERNAL_PORT=1883
MQTT_WS_PORT=9001
@@ -69,6 +74,10 @@ SERIAL_PORT_SENDER=/dev/ttyUSB1
# Baud rate for serial communication
SERIAL_BAUD=115200
# Optional device/node name to set on startup
# This name is broadcast to the mesh network in advertisements
MESHCORE_DEVICE_NAME=
# Optional node address override (64-char hex string)
# Only set if you need to override the device's public key
NODE_ADDRESS=
@@ -137,3 +146,35 @@ WEBHOOK_MESSAGE_SECRET=
WEBHOOK_TIMEOUT=10.0
WEBHOOK_MAX_RETRIES=3
WEBHOOK_RETRY_BACKOFF=2.0
# ===================
# Data Retention Settings
# ===================
# Enable automatic cleanup of old event data
# When enabled, the collector runs periodic cleanup to delete old events
# Default: true
DATA_RETENTION_ENABLED=true
# Number of days to retain event data (advertisements, messages, telemetry, etc.)
# Events older than this are deleted during cleanup
# Default: 30 days
DATA_RETENTION_DAYS=30
# Hours between automatic cleanup runs (applies to both events and nodes)
# Default: 24 hours (once per day)
DATA_RETENTION_INTERVAL_HOURS=24
# ===================
# Node Cleanup Settings
# ===================
# Enable automatic cleanup of inactive nodes
# Nodes that haven't been seen (last_seen) for the specified period are removed
# Nodes with last_seen=NULL (never seen on network) are NOT removed
# Default: true
NODE_CLEANUP_ENABLED=true
# Remove nodes not seen for this many days (based on last_seen field)
# Default: 7 days
NODE_CLEANUP_DAYS=7
+2 -2
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
lint:
-1
View File
@@ -47,4 +47,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+3 -5
View File
@@ -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
View File
@@ -218,4 +218,3 @@ __marimo__/
# MeshCore Hub specific
*.db
meshcore.db
src/meshcore_hub/_version.py
+57
View File
@@ -264,6 +264,7 @@ meshcore-hub/
│ ├── collector/
│ │ ├── cli.py # Collector CLI with seed commands
│ │ ├── subscriber.py # MQTT subscriber
│ │ ├── cleanup.py # Data retention/cleanup service
│ │ ├── tag_import.py # Tag import from YAML
│ │ ├── member_import.py # Member import from YAML
│ │ ├── handlers/ # Event handlers
@@ -502,6 +503,48 @@ The collector supports forwarding events to external HTTP endpoints:
| `WEBHOOK_MAX_RETRIES` | Max retries on failure (default: 3) |
| `WEBHOOK_RETRY_BACKOFF` | Exponential backoff multiplier (default: 2.0) |
### Data Retention / Cleanup Configuration
The collector supports automatic cleanup of old event data and inactive nodes:
**Event Data Cleanup:**
| Variable | Description |
|----------|-------------|
| `DATA_RETENTION_ENABLED` | Enable automatic event data cleanup (default: true) |
| `DATA_RETENTION_DAYS` | Days to retain event data (default: 30) |
| `DATA_RETENTION_INTERVAL_HOURS` | Hours between cleanup runs (default: 24) |
When enabled, the collector automatically deletes event data older than the retention period:
- Advertisements
- Messages (channel and direct)
- Telemetry
- Trace paths
- Event logs
**Node Cleanup:**
| Variable | Description |
|----------|-------------|
| `NODE_CLEANUP_ENABLED` | Enable automatic cleanup of inactive nodes (default: true) |
| `NODE_CLEANUP_DAYS` | Remove nodes not seen for this many days (default: 7) |
When enabled, the collector automatically removes nodes where:
- `last_seen` is older than the configured number of days
- Nodes with `last_seen=NULL` (never seen on network) are **NOT** removed
- Nodes created via tag import that have never been seen on the mesh are preserved
**Note:** Both event data and node cleanup run on the same schedule (DATA_RETENTION_INTERVAL_HOURS).
Manual cleanup can be triggered at any time with:
```bash
# Dry run to see what would be deleted
meshcore-hub collector cleanup --retention-days 30 --dry-run
# Live cleanup
meshcore-hub collector cleanup --retention-days 30
```
Webhook payload structure:
```json
{
@@ -581,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
+7 -6
View File
@@ -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
+1
View File
@@ -481,6 +481,7 @@ ${DATA_HOME}/
| INTERFACE_MODE | RECEIVER | RECEIVER or SENDER |
| SERIAL_PORT | /dev/ttyUSB0 | Serial port path |
| SERIAL_BAUD | 115200 | Baud rate |
| MESHCORE_DEVICE_NAME | *(none)* | Device/node name set on startup |
| MOCK_DEVICE | false | Use mock device |
### Collector
+56 -1
View File
@@ -2,6 +2,8 @@
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)
## Overview
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
@@ -235,6 +237,57 @@ meshcore-hub api
meshcore-hub web
```
## Updating an Existing Installation
To update MeshCore Hub to the latest version:
```bash
# Navigate to your installation directory
cd meshcore-hub
# Pull the latest code
git pull
# Pull latest Docker images
docker compose --profile all pull
# Recreate and restart services
# For receiver/sender only installs:
docker compose --profile receiver up -d --force-recreate
# For core services with MQTT:
docker compose --profile mqtt --profile core up -d --force-recreate
# For core services without local MQTT:
docker compose --profile core up -d --force-recreate
# For complete stack (all services):
docker compose --profile mqtt --profile core --profile receiver up -d --force-recreate
# View logs to verify update
docker compose logs -f
```
**Note:** Database migrations run automatically on collector startup, so no manual migration step is needed when using Docker.
For manual installations:
```bash
# Pull latest code
git pull
# Activate virtual environment
source .venv/bin/activate
# Update dependencies
pip install -e ".[dev]"
# Run database migrations
meshcore-hub db upgrade
# Restart your services
```
## Configuration
All components are configured via environment variables. Create a `.env` file or export variables:
@@ -255,6 +308,7 @@ All components are configured via environment variables. Create a `.env` file or
| `INTERFACE_MODE` | `RECEIVER` | Operating mode (RECEIVER or SENDER) |
| `SERIAL_PORT` | `/dev/ttyUSB0` | Serial port for MeshCore device |
| `SERIAL_BAUD` | `115200` | Serial baud rate |
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
| `MOCK_DEVICE` | `false` | Use mock device for testing |
### Collector Settings
@@ -266,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 |
|----------|---------|-------------|
@@ -317,6 +371,7 @@ meshcore-hub --help
# Interface component
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
meshcore-hub interface --mode receiver --device-name "Gateway Node" # Set device name
meshcore-hub interface --mode sender --mock # Use mock device
# Collector component
@@ -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,
)
-66
View File
@@ -1,66 +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: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80
node_role: repeater
- name: CCZ
callsign: CCZ
role: member
nodes:
- public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459
node_role: repeater
- public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53
node_role: repeater
- public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac
node_role: repeater
description: IPNet Member
- name: Walshie
callsign: Walshie86
role: member
description: IPNet Member
nodes:
- public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52
node_role: repeater
- public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2
node_role: repeater
- name: Craig
callsign: M7XCN
role: member
description: IPNet Member
nodes:
- public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3
node_role: repeater
-239
View File
@@ -1,239 +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
# IP4 Area Nodes
d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a:
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
b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80:
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
+23
View File
@@ -47,6 +47,7 @@ services:
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- SERIAL_PORT=${SERIAL_PORT:-/dev/ttyUSB0}
- SERIAL_BAUD=${SERIAL_BAUD:-115200}
- NODE_ADDRESS=${NODE_ADDRESS:-}
@@ -81,6 +82,7 @@ services:
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- SERIAL_PORT=${SERIAL_PORT_SENDER:-/dev/ttyUSB1}
- SERIAL_BAUD=${SERIAL_BAUD:-115200}
- NODE_ADDRESS=${NODE_ADDRESS_SENDER:-}
@@ -112,6 +114,7 @@ services:
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- MOCK_DEVICE=true
- NODE_ADDRESS=${NODE_ADDRESS:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
command: ["interface", "receiver", "--mock"]
@@ -135,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
@@ -145,6 +151,7 @@ services:
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- DATA_HOME=/data
- SEED_HOME=/seed
# Explicitly unset to use DATA_HOME-based default path
@@ -161,6 +168,12 @@ services:
- WEBHOOK_TIMEOUT=${WEBHOOK_TIMEOUT:-10.0}
- WEBHOOK_MAX_RETRIES=${WEBHOOK_MAX_RETRIES:-3}
- WEBHOOK_RETRY_BACKOFF=${WEBHOOK_RETRY_BACKOFF:-2.0}
# Data retention and cleanup configuration
- DATA_RETENTION_ENABLED=${DATA_RETENTION_ENABLED:-true}
- DATA_RETENTION_DAYS=${DATA_RETENTION_DAYS:-30}
- DATA_RETENTION_INTERVAL_HOURS=${DATA_RETENTION_INTERVAL_HOURS:-24}
- NODE_CLEANUP_ENABLED=${NODE_CLEANUP_ENABLED:-true}
- NODE_CLEANUP_DAYS=${NODE_CLEANUP_DAYS:-7}
command: ["collector"]
healthcheck:
test: ["CMD", "meshcore-hub", "health", "collector"]
@@ -183,6 +196,8 @@ services:
- core
restart: unless-stopped
depends_on:
seed:
condition: service_completed_successfully
collector:
condition: service_started
ports:
@@ -197,6 +212,7 @@ services:
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- DATA_HOME=/data
# Explicitly unset to use DATA_HOME-based default path
- DATABASE_URL=
@@ -263,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
@@ -284,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+8 -10
View File
@@ -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
+2 -6
View File
@@ -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"]
+2 -2
View File
@@ -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
View 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__"]
+3
View File
@@ -52,6 +52,7 @@ def create_app(
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
cors_origins: list[str] | None = None,
) -> FastAPI:
"""Create and configure the FastAPI application.
@@ -63,6 +64,7 @@ def create_app(
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
cors_origins: Allowed CORS origins
Returns:
@@ -85,6 +87,7 @@ def create_app(
app.state.mqtt_host = mqtt_host
app.state.mqtt_port = mqtt_port
app.state.mqtt_prefix = mqtt_prefix
app.state.mqtt_tls = mqtt_tls
# Configure CORS
if cors_origins is None:
+9
View File
@@ -67,6 +67,13 @@ import click
envvar="MQTT_TOPIC_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
@click.option(
"--cors-origins",
type=str,
@@ -92,6 +99,7 @@ def api(
mqtt_host: str,
mqtt_port: int,
mqtt_prefix: str,
mqtt_tls: bool,
cors_origins: str | None,
reload: bool,
) -> None:
@@ -171,6 +179,7 @@ def api(
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
cors_origins=origins_list,
)
+2
View File
@@ -57,6 +57,7 @@ def get_mqtt_client(request: Request) -> MQTTClient:
mqtt_host = getattr(request.app.state, "mqtt_host", "localhost")
mqtt_port = getattr(request.app.state, "mqtt_port", 1883)
mqtt_prefix = getattr(request.app.state, "mqtt_prefix", "meshcore")
mqtt_tls = getattr(request.app.state, "mqtt_tls", False)
# Use unique client ID to allow multiple API instances
unique_id = uuid.uuid4().hex[:8]
@@ -65,6 +66,7 @@ def get_mqtt_client(request: Request) -> MQTTClient:
port=mqtt_port,
prefix=mqtt_prefix,
client_id=f"meshcore-api-{unique_id}",
tls=mqtt_tls,
)
client = MQTTClient(config)
+33 -14
View File
@@ -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,
+52 -27
View File
@@ -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
+29 -138
View File
@@ -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)
+20 -20
View File
@@ -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,
+21 -7
View File
@@ -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
+225
View File
@@ -0,0 +1,225 @@
"""Data retention and cleanup service for MeshCore Hub.
This module provides functionality to delete old event data and inactive nodes
based on configured retention policies.
"""
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshcore_hub.common.models import (
Advertisement,
EventLog,
Message,
Node,
Telemetry,
TracePath,
)
logger = logging.getLogger(__name__)
class CleanupStats:
"""Statistics from a cleanup operation."""
def __init__(self) -> None:
self.advertisements_deleted: int = 0
self.messages_deleted: int = 0
self.telemetry_deleted: int = 0
self.trace_paths_deleted: int = 0
self.event_logs_deleted: int = 0
self.nodes_deleted: int = 0
self.total_deleted: int = 0
def __repr__(self) -> str:
return (
f"CleanupStats(total={self.total_deleted}, "
f"advertisements={self.advertisements_deleted}, "
f"messages={self.messages_deleted}, "
f"telemetry={self.telemetry_deleted}, "
f"trace_paths={self.trace_paths_deleted}, "
f"event_logs={self.event_logs_deleted}, "
f"nodes={self.nodes_deleted})"
)
async def cleanup_old_data(
db: AsyncSession,
retention_days: int,
dry_run: bool = False,
) -> CleanupStats:
"""Delete event data older than the retention period.
Args:
db: Database session
retention_days: Number of days to retain data
dry_run: If True, only count records without deleting
Returns:
CleanupStats object with deletion counts
"""
stats = CleanupStats()
cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days)
logger.info(
"Starting data cleanup (dry_run=%s, retention_days=%d, cutoff=%s)",
dry_run,
retention_days,
cutoff_date.isoformat(),
)
# Clean up advertisements
stats.advertisements_deleted = await _cleanup_table(
db, Advertisement, cutoff_date, "advertisements", dry_run
)
# Clean up messages
stats.messages_deleted = await _cleanup_table(
db, Message, cutoff_date, "messages", dry_run
)
# Clean up telemetry
stats.telemetry_deleted = await _cleanup_table(
db, Telemetry, cutoff_date, "telemetry", dry_run
)
# Clean up trace paths
stats.trace_paths_deleted = await _cleanup_table(
db, TracePath, cutoff_date, "trace_paths", dry_run
)
# Clean up event logs
stats.event_logs_deleted = await _cleanup_table(
db, EventLog, cutoff_date, "event_logs", dry_run
)
stats.total_deleted = (
stats.advertisements_deleted
+ stats.messages_deleted
+ stats.telemetry_deleted
+ stats.trace_paths_deleted
+ stats.event_logs_deleted
)
if not dry_run:
await db.commit()
logger.info("Cleanup completed: %s", stats)
else:
logger.info("Cleanup dry run completed: %s", stats)
return stats
async def _cleanup_table(
db: AsyncSession,
model: type,
cutoff_date: datetime,
table_name: str,
dry_run: bool,
) -> int:
"""Delete old records from a specific table.
Args:
db: Database session
model: SQLAlchemy model class
cutoff_date: Delete records older than this date
table_name: Name of table for logging
dry_run: If True, only count without deleting
Returns:
Number of records deleted (or would be deleted in dry_run)
"""
from sqlalchemy import select
if dry_run:
# Count records that would be deleted
stmt = (
select(func.count())
.select_from(model)
.where(model.created_at < cutoff_date) # type: ignore[attr-defined]
)
result = await db.execute(stmt)
count = result.scalar() or 0
logger.debug(
"[DRY RUN] Would delete %d records from %s older than %s",
count,
table_name,
cutoff_date.isoformat(),
)
return count
else:
# Delete old records
result = await db.execute(delete(model).where(model.created_at < cutoff_date)) # type: ignore[attr-defined]
count = result.rowcount or 0 # type: ignore[attr-defined]
logger.debug(
"Deleted %d records from %s older than %s",
count,
table_name,
cutoff_date.isoformat(),
)
return count
async def cleanup_inactive_nodes(
db: AsyncSession,
inactivity_days: int,
dry_run: bool = False,
) -> int:
"""Delete nodes that haven't been seen for the specified number of days.
Only deletes nodes where last_seen is older than the cutoff date.
Nodes with last_seen=NULL are NOT deleted (never seen on network).
Args:
db: Database session
inactivity_days: Delete nodes not seen for this many days
dry_run: If True, only count without deleting
Returns:
Number of nodes deleted (or would be deleted in dry_run)
"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=inactivity_days)
logger.info(
"Starting node cleanup (dry_run=%s, inactivity_days=%d, cutoff=%s)",
dry_run,
inactivity_days,
cutoff_date.isoformat(),
)
if dry_run:
# Count nodes that would be deleted
# Only count nodes with last_seen < cutoff (excludes NULL last_seen)
stmt = (
select(func.count())
.select_from(Node)
.where(Node.last_seen < cutoff_date)
.where(Node.last_seen.isnot(None))
)
result = await db.execute(stmt)
count = result.scalar() or 0
logger.info(
"[DRY RUN] Would delete %d nodes not seen since %s",
count,
cutoff_date.isoformat(),
)
return count
else:
# Delete inactive nodes
# Only delete nodes with last_seen < cutoff (excludes NULL last_seen)
result = await db.execute(
delete(Node)
.where(Node.last_seen < cutoff_date)
.where(Node.last_seen.isnot(None))
)
await db.commit()
count = result.rowcount or 0 # type: ignore[attr-defined]
logger.info(
"Deleted %d nodes not seen since %s",
count,
cutoff_date.isoformat(),
)
return count
+351 -28
View File
@@ -47,6 +47,13 @@ if TYPE_CHECKING:
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
@click.option(
"--data-home",
type=str,
@@ -82,6 +89,7 @@ def collector(
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
data_home: str | None,
seed_home: str | None,
database_url: str | None,
@@ -125,6 +133,7 @@ def collector(
ctx.obj["mqtt_username"] = mqtt_username
ctx.obj["mqtt_password"] = mqtt_password
ctx.obj["prefix"] = prefix
ctx.obj["mqtt_tls"] = mqtt_tls
ctx.obj["data_home"] = data_home or settings.data_home
ctx.obj["seed_home"] = settings.effective_seed_home
ctx.obj["database_url"] = effective_db_url
@@ -139,6 +148,7 @@ def collector(
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
prefix=prefix,
mqtt_tls=mqtt_tls,
database_url=effective_db_url,
log_level=log_level,
data_home=data_home or settings.data_home,
@@ -152,6 +162,7 @@ def _run_collector_service(
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
database_url: str,
log_level: str,
data_home: str,
@@ -159,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
@@ -182,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,
@@ -228,6 +214,26 @@ def _run_collector_service(
from meshcore_hub.collector.subscriber import run_collector
# Show cleanup configuration
click.echo("")
click.echo("Cleanup configuration:")
if settings.data_retention_enabled:
click.echo(
f" Event data: Enabled (retention: {settings.data_retention_days} days)"
)
else:
click.echo(" Event data: Disabled")
if settings.node_cleanup_enabled:
click.echo(
f" Inactive nodes: Enabled (inactivity: {settings.node_cleanup_days} days)"
)
else:
click.echo(" Inactive nodes: Disabled")
if settings.data_retention_enabled or settings.node_cleanup_enabled:
click.echo(f" Interval: {settings.data_retention_interval_hours} hours")
click.echo("")
click.echo("Starting MQTT subscriber...")
run_collector(
@@ -236,8 +242,14 @@ def _run_collector_service(
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
database_url=database_url,
webhook_dispatcher=webhook_dispatcher,
cleanup_enabled=settings.data_retention_enabled,
cleanup_retention_days=settings.data_retention_days,
cleanup_interval_hours=settings.data_retention_interval_hours,
node_cleanup_enabled=settings.node_cleanup_enabled,
node_cleanup_days=settings.node_cleanup_days,
)
@@ -254,6 +266,7 @@ def run_cmd(ctx: click.Context) -> None:
mqtt_username=ctx.obj["mqtt_username"],
mqtt_password=ctx.obj["mqtt_password"],
prefix=ctx.obj["prefix"],
mqtt_tls=ctx.obj["mqtt_tls"],
database_url=ctx.obj["database_url"],
log_level=ctx.obj["log_level"],
data_home=ctx.obj["data_home"],
@@ -345,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"
)
@@ -390,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.
@@ -454,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']}")
@@ -549,3 +576,299 @@ def import_members_cmd(
click.echo(f" - {error}", err=True)
db.dispose()
@collector.command("cleanup")
@click.option(
"--retention-days",
type=int,
default=30,
envvar="DATA_RETENTION_DAYS",
help="Number of days to retain data (default: 30)",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Show what would be deleted without deleting",
)
@click.pass_context
def cleanup_cmd(
ctx: click.Context,
retention_days: int,
dry_run: bool,
) -> None:
"""Manually run data cleanup to delete old events.
Deletes event data older than the retention period:
- Advertisements
- Messages (channel and direct)
- Telemetry
- Trace paths
- Event logs
Node records are never deleted - only event data.
Use --dry-run to preview what would be deleted without
actually deleting anything.
"""
import asyncio
configure_logging(level=ctx.obj["log_level"])
click.echo(f"Database: {ctx.obj['database_url']}")
click.echo(f"Retention: {retention_days} days")
click.echo(f"Mode: {'DRY RUN' if dry_run else 'LIVE'}")
click.echo("")
if dry_run:
click.echo("Running in dry-run mode - no data will be deleted.")
else:
click.echo("WARNING: This will permanently delete old event data!")
if not click.confirm("Continue?"):
click.echo("Aborted.")
return
click.echo("")
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.collector.cleanup import cleanup_old_data
# Initialize database
db = DatabaseManager(ctx.obj["database_url"])
# Run cleanup
async def run_cleanup() -> None:
async with db.async_session() as session:
stats = await cleanup_old_data(
session,
retention_days,
dry_run=dry_run,
)
click.echo("")
click.echo("Cleanup results:")
click.echo(f" Advertisements: {stats.advertisements_deleted}")
click.echo(f" Messages: {stats.messages_deleted}")
click.echo(f" Telemetry: {stats.telemetry_deleted}")
click.echo(f" Trace paths: {stats.trace_paths_deleted}")
click.echo(f" Event logs: {stats.event_logs_deleted}")
click.echo(f" Total: {stats.total_deleted}")
if dry_run:
click.echo("")
click.echo("(Dry run - no data was actually deleted)")
asyncio.run(run_cleanup())
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})")
+28 -62
View File
@@ -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)
+176 -1
View File
@@ -6,6 +6,7 @@ The subscriber:
3. Routes events to appropriate handlers
4. Persists data to database
5. Dispatches events to configured webhooks
6. Performs scheduled data cleanup if enabled
"""
import asyncio
@@ -14,6 +15,7 @@ import signal
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Optional, TYPE_CHECKING
from meshcore_hub.common.database import DatabaseManager
@@ -38,6 +40,11 @@ class Subscriber:
mqtt_client: MQTTClient,
db_manager: DatabaseManager,
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
cleanup_retention_days: int = 30,
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
):
"""Initialize subscriber.
@@ -45,6 +52,11 @@ class Subscriber:
mqtt_client: MQTT client instance
db_manager: Database manager instance
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
cleanup_retention_days: Number of days to retain event data
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
"""
self.mqtt = mqtt_client
self.db = db_manager
@@ -59,6 +71,14 @@ class Subscriber:
self._webhook_queue: list[tuple[str, dict[str, Any], str]] = []
self._webhook_lock = threading.Lock()
self._webhook_thread: Optional[threading.Thread] = None
# Data cleanup
self._cleanup_enabled = cleanup_enabled
self._cleanup_retention_days = cleanup_retention_days
self._cleanup_interval_hours = cleanup_interval_hours
self._node_cleanup_enabled = node_cleanup_enabled
self._node_cleanup_days = node_cleanup_days
self._cleanup_thread: Optional[threading.Thread] = None
self._last_cleanup: Optional[datetime] = None
@property
def is_healthy(self) -> bool:
@@ -202,6 +222,115 @@ class Subscriber:
if self._webhook_thread.is_alive():
logger.warning("Webhook processor thread did not stop cleanly")
def _start_cleanup_scheduler(self) -> None:
"""Start background thread for periodic data cleanup."""
if not self._cleanup_enabled and not self._node_cleanup_enabled:
logger.info("Data cleanup and node cleanup are both disabled")
return
logger.info(
"Starting cleanup scheduler (interval_hours=%d)",
self._cleanup_interval_hours,
)
if self._cleanup_enabled:
logger.info(
" Event data cleanup: ENABLED (retention_days=%d)",
self._cleanup_retention_days,
)
else:
logger.info(" Event data cleanup: DISABLED")
if self._node_cleanup_enabled:
logger.info(
" Node cleanup: ENABLED (inactivity_days=%d)", self._node_cleanup_days
)
else:
logger.info(" Node cleanup: DISABLED")
def run_cleanup_loop() -> None:
"""Run async cleanup tasks in background thread."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
while self._running:
# Check if cleanup is due
now = datetime.now(timezone.utc)
should_run = False
if self._last_cleanup is None:
# First run
should_run = True
else:
# Check if interval has passed
hours_since_last = (
now - self._last_cleanup
).total_seconds() / 3600
should_run = hours_since_last >= self._cleanup_interval_hours
if should_run:
try:
logger.info("Starting scheduled cleanup")
from meshcore_hub.collector.cleanup import (
cleanup_old_data,
cleanup_inactive_nodes,
)
# Get async session and run cleanup
async def run_cleanup() -> None:
async with self.db.async_session() as session:
# Run event data cleanup if enabled
if self._cleanup_enabled:
stats = await cleanup_old_data(
session,
self._cleanup_retention_days,
dry_run=False,
)
logger.info(
"Event cleanup completed: %s", stats
)
# Run node cleanup if enabled
if self._node_cleanup_enabled:
nodes_deleted = await cleanup_inactive_nodes(
session,
self._node_cleanup_days,
dry_run=False,
)
logger.info(
"Node cleanup completed: %d nodes deleted",
nodes_deleted,
)
loop.run_until_complete(run_cleanup())
self._last_cleanup = now
except Exception as e:
logger.error(f"Cleanup error: {e}", exc_info=True)
# Sleep for 1 hour before next check
for _ in range(3600):
if not self._running:
break
time.sleep(1)
finally:
loop.close()
logger.info("Cleanup scheduler stopped")
self._cleanup_thread = threading.Thread(
target=run_cleanup_loop, daemon=True, name="cleanup-scheduler"
)
self._cleanup_thread.start()
def _stop_cleanup_scheduler(self) -> None:
"""Stop the cleanup scheduler thread."""
if self._cleanup_thread and self._cleanup_thread.is_alive():
# Thread will exit when self._running becomes False
self._cleanup_thread.join(timeout=5.0)
if self._cleanup_thread.is_alive():
logger.warning("Cleanup scheduler thread did not stop cleanly")
def start(self) -> None:
"""Start the subscriber."""
logger.info("Starting collector subscriber")
@@ -239,6 +368,9 @@ class Subscriber:
# Start webhook processor if configured
self._start_webhook_processor()
# Start cleanup scheduler if configured
self._start_cleanup_scheduler()
# Start health reporter for Docker health checks
self._health_reporter = HealthReporter(
component="collector",
@@ -271,6 +403,9 @@ class Subscriber:
self._running = False
self._shutdown_event.set()
# Stop cleanup scheduler
self._stop_cleanup_scheduler()
# Stop webhook processor
self._stop_webhook_processor()
@@ -293,8 +428,14 @@ def create_subscriber(
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
database_url: str = "sqlite:///./meshcore.db",
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
cleanup_retention_days: int = 30,
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
) -> Subscriber:
"""Create a configured subscriber instance.
@@ -304,8 +445,14 @@ def create_subscriber(
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
database_url: Database connection URL
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
cleanup_retention_days: Number of days to retain event data
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
Returns:
Configured Subscriber instance
@@ -319,6 +466,7 @@ def create_subscriber(
password=mqtt_password,
prefix=mqtt_prefix,
client_id=f"meshcore-collector-{unique_id}",
tls=mqtt_tls,
)
mqtt_client = MQTTClient(mqtt_config)
@@ -326,7 +474,16 @@ def create_subscriber(
db_manager = DatabaseManager(database_url)
# Create subscriber
subscriber = Subscriber(mqtt_client, db_manager, webhook_dispatcher)
subscriber = Subscriber(
mqtt_client,
db_manager,
webhook_dispatcher,
cleanup_enabled=cleanup_enabled,
cleanup_retention_days=cleanup_retention_days,
cleanup_interval_hours=cleanup_interval_hours,
node_cleanup_enabled=node_cleanup_enabled,
node_cleanup_days=node_cleanup_days,
)
# Register handlers
from meshcore_hub.collector.handlers import register_all_handlers
@@ -342,8 +499,14 @@ def run_collector(
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
database_url: str = "sqlite:///./meshcore.db",
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
cleanup_retention_days: int = 30,
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
) -> None:
"""Run the collector (blocking).
@@ -353,8 +516,14 @@ def run_collector(
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
database_url: Database connection URL
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
cleanup_retention_days: Number of days to retain event data
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
"""
subscriber = create_subscriber(
mqtt_host=mqtt_host,
@@ -362,8 +531,14 @@ def run_collector(
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
database_url=database_url,
webhook_dispatcher=webhook_dispatcher,
cleanup_enabled=cleanup_enabled,
cleanup_retention_days=cleanup_retention_days,
cleanup_interval_hours=cleanup_interval_hours,
node_cleanup_enabled=node_cleanup_enabled,
node_cleanup_days=node_cleanup_days,
)
# Set up signal handlers
+52 -20
View File
@@ -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] = {}
@@ -210,7 +224,8 @@ def import_tags(
node = Node(
public_key=public_key,
first_seen=now,
last_seen=now,
# last_seen is intentionally left unset (None)
# It will be set when the node is actually seen via events
)
session.add(node)
session.flush()
@@ -231,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,
@@ -261,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}"
+31
View File
@@ -52,6 +52,9 @@ class CommonSettings(BaseSettings):
default=None, description="MQTT password (optional)"
)
mqtt_prefix: str = Field(default="meshcore", description="MQTT topic prefix")
mqtt_tls: bool = Field(
default=False, description="Enable TLS/SSL for MQTT connection"
)
class InterfaceSettings(CommonSettings):
@@ -70,6 +73,11 @@ class InterfaceSettings(CommonSettings):
# Mock device
mock_device: bool = Field(default=False, description="Use mock device for testing")
# Device name
meshcore_device_name: Optional[str] = Field(
default=None, description="Device/node name (optional)"
)
class CollectorSettings(CommonSettings):
"""Settings for the Collector component."""
@@ -121,6 +129,29 @@ class CollectorSettings(CommonSettings):
default=2.0, description="Retry backoff multiplier"
)
# Data retention / cleanup settings
data_retention_enabled: bool = Field(
default=True, description="Enable automatic event data cleanup"
)
data_retention_days: int = Field(
default=30, description="Number of days to retain event data", ge=1
)
data_retention_interval_hours: int = Field(
default=24,
description="Hours between automatic cleanup runs (applies to both events and nodes)",
ge=1,
)
# Node cleanup settings
node_cleanup_enabled: bool = Field(
default=True, description="Enable automatic cleanup of inactive nodes"
)
node_cleanup_days: int = Field(
default=7,
description="Remove nodes not seen for this many days (last_seen)",
ge=1,
)
@property
def collector_data_dir(self) -> str:
"""Get the collector data directory path."""
+29 -2
View File
@@ -1,10 +1,11 @@
"""Database connection and session management."""
from contextlib import contextmanager
from typing import Generator
from contextlib import asynccontextmanager, contextmanager
from typing import AsyncGenerator, Generator
from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
from meshcore_hub.common.models.base import Base
@@ -100,6 +101,17 @@ class DatabaseManager:
self.engine = create_database_engine(database_url, echo=echo)
self.session_factory = create_session_factory(self.engine)
# Create async engine for async operations
async_url = database_url.replace("sqlite://", "sqlite+aiosqlite://")
self.async_engine = create_async_engine(async_url, echo=echo)
from sqlalchemy.ext.asyncio import async_sessionmaker
self.async_session_factory = async_sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
def create_tables(self) -> None:
"""Create all database tables."""
create_tables(self.engine)
@@ -138,6 +150,21 @@ class DatabaseManager:
finally:
session.close()
@asynccontextmanager
async def async_session(self) -> AsyncGenerator[AsyncSession, None]:
"""Provide an async session context manager.
Yields:
AsyncSession instance
Example:
async with db.async_session() as session:
result = await session.execute(select(Node))
await session.commit()
"""
async with self.async_session_factory() as session:
yield session
def dispose(self) -> None:
"""Dispose of the database engine and connection pool."""
self.engine.dispose()
@@ -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",
]
+11 -14
View File
@@ -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})>"
+3 -3
View File
@@ -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
+9
View File
@@ -23,6 +23,7 @@ class MQTTConfig:
client_id: Optional[str] = None
keepalive: int = 60
clean_session: bool = True
tls: bool = False
class TopicBuilder:
@@ -131,6 +132,11 @@ class MQTTClient:
self._connected = False
self._message_handlers: dict[str, list[MessageHandler]] = {}
# Set up TLS if enabled
if config.tls:
self._client.tls_set()
logger.debug("TLS/SSL enabled for MQTT connection")
# Set up authentication if provided
if config.username:
self._client.username_pw_set(config.username, config.password)
@@ -344,6 +350,7 @@ def create_mqtt_client(
password: Optional[str] = None,
prefix: str = "meshcore",
client_id: Optional[str] = None,
tls: bool = False,
) -> MQTTClient:
"""Create and configure an MQTT client.
@@ -354,6 +361,7 @@ def create_mqtt_client(
password: MQTT password (optional)
prefix: Topic prefix
client_id: Client identifier (optional)
tls: Enable TLS/SSL connection (optional)
Returns:
Configured MQTTClient instance
@@ -365,5 +373,6 @@ def create_mqtt_client(
password=password,
prefix=prefix,
client_id=client_id,
tls=tls,
)
return MQTTClient(config)
+28 -49
View File
@@ -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")
+16 -14
View File
@@ -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"
)
+4 -2
View File
@@ -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,
+54
View File
@@ -51,6 +51,13 @@ def interface() -> None:
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
@@ -86,6 +93,13 @@ def interface() -> None:
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
@@ -99,11 +113,13 @@ def run(
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
log_level: str,
) -> None:
"""Run the interface component.
@@ -139,11 +155,13 @@ def run(
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
elif mode_upper == "SENDER":
from meshcore_hub.interface.sender import run_sender
@@ -153,11 +171,13 @@ def run(
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
else:
click.echo(f"Unknown mode: {mode}", err=True)
@@ -193,6 +213,13 @@ def run(
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
@@ -228,16 +255,25 @@ def run(
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
def receiver(
port: str,
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
) -> None:
"""Run interface in RECEIVER mode.
@@ -262,6 +298,7 @@ def receiver(
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
@@ -294,6 +331,13 @@ def receiver(
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
@@ -329,16 +373,25 @@ def receiver(
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
def sender(
port: str,
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
) -> None:
"""Run interface in SENDER mode.
@@ -363,4 +416,5 @@ def sender(
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
+74 -1
View File
@@ -164,6 +164,18 @@ class BaseMeshCoreDevice(ABC):
"""
pass
@abstractmethod
def set_name(self, name: str) -> bool:
"""Set the device's node name.
Args:
name: Node name to set
Returns:
True if name was set successfully
"""
pass
@abstractmethod
def start_message_fetching(self) -> bool:
"""Start automatic message fetching.
@@ -181,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)."""
@@ -518,6 +543,24 @@ class MeshCoreDevice(BaseMeshCoreDevice):
logger.error(f"Failed to set device time: {e}")
return False
def set_name(self, name: str) -> bool:
"""Set the device's node name."""
if not self._connected or not self._mc:
logger.error("Cannot set name: not connected")
return False
try:
async def _set_name() -> None:
await self._mc.commands.set_name(name)
self._loop.run_until_complete(_set_name())
logger.info(f"Set device name to '{name}'")
return True
except Exception as e:
logger.error(f"Failed to set device name: {e}")
return False
def start_message_fetching(self) -> bool:
"""Start automatic message fetching."""
if not self._connected or not self._mc:
@@ -537,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
@@ -554,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
+23 -1
View File
@@ -271,6 +271,17 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
logger.info(f"Mock: Set device time to {timestamp}")
return True
def set_name(self, name: str) -> bool:
"""Set the mock device's node name."""
if not self._connected:
logger.error("Cannot set name: not connected")
return False
logger.info(f"Mock: Set device name to '{name}'")
# Update the mock config name
self.mock_config.name = name
return True
def start_message_fetching(self) -> bool:
"""Start automatic message fetching (mock)."""
if not self._connected:
@@ -281,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
@@ -307,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
+49 -10
View File
@@ -33,15 +33,18 @@ class Receiver:
self,
device: BaseMeshCoreDevice,
mqtt_client: MQTTClient,
device_name: Optional[str] = None,
):
"""Initialize receiver.
Args:
device: MeshCore device instance
mqtt_client: MQTT client instance
device_name: Optional device/node name to set on startup
"""
self.device = device
self.mqtt = mqtt_client
self.device_name = device_name
self._running = False
self._shutdown_event = threading.Event()
self._device_connected = False
@@ -71,11 +74,14 @@ class Receiver:
"device_public_key": self.device.public_key,
}
def _initialize_device(self) -> None:
def _initialize_device(self, device_name: Optional[str] = None) -> None:
"""Initialize device after connection.
Sets the hardware clock, sends a local advertisement, starts message fetching,
and syncs the contact database.
Sets the hardware clock, optionally sets device name, sends a local advertisement,
starts message fetching, and syncs the contact database.
Args:
device_name: Optional device/node name to set
"""
# Set device time to current Unix timestamp
current_time = int(time.time())
@@ -84,11 +90,18 @@ class Receiver:
else:
logger.warning("Failed to synchronize device clock")
# Send a local (non-flood) advertisement to announce presence
if self.device.send_advertisement(flood=False):
logger.info("Sent local advertisement")
# Set device name if provided
if device_name:
if self.device.set_name(device_name):
logger.info(f"Set device name to '{device_name}'")
else:
logger.warning(f"Failed to set device name to '{device_name}'")
# Send a flood advertisement to broadcast device name
if self.device.send_advertisement(flood=True):
logger.info("Sent flood advertisement")
else:
logger.warning("Failed to send local advertisement")
logger.warning("Failed to send flood advertisement")
# Start automatic message fetching
if self.device.start_message_fetching():
@@ -131,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.
@@ -211,8 +239,8 @@ class Receiver:
self._device_connected = True
# Initialize device: set time and send local advertisement
self._initialize_device()
# Initialize device: set time, optionally set name, and send local advertisement
self._initialize_device(device_name=self.device_name)
self._running = True
@@ -271,11 +299,13 @@ def create_receiver(
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> Receiver:
"""Create a configured receiver instance.
@@ -284,11 +314,13 @@ def create_receiver(
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name to set on startup
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
Returns:
Configured Receiver instance
@@ -309,10 +341,11 @@ def create_receiver(
password=mqtt_password,
prefix=mqtt_prefix,
client_id=f"meshcore-receiver-{device.public_key[:12] if device.public_key else 'unknown'}",
tls=mqtt_tls,
)
mqtt_client = MQTTClient(mqtt_config)
return Receiver(device, mqtt_client)
return Receiver(device, mqtt_client, device_name=device_name)
def run_receiver(
@@ -320,11 +353,13 @@ def run_receiver(
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> None:
"""Run the receiver (blocking).
@@ -335,22 +370,26 @@ def run_receiver(
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name to set on startup
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
"""
receiver = create_receiver(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
)
# Set up signal handlers
+11
View File
@@ -287,11 +287,13 @@ def create_sender(
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> Sender:
"""Create a configured sender instance.
@@ -300,11 +302,13 @@ def create_sender(
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name (not used in SENDER mode)
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
Returns:
Configured Sender instance
@@ -325,6 +329,7 @@ def create_sender(
password=mqtt_password,
prefix=mqtt_prefix,
client_id=f"meshcore-sender-{device.public_key[:12] if device.public_key else 'unknown'}",
tls=mqtt_tls,
)
mqtt_client = MQTTClient(mqtt_config)
@@ -336,11 +341,13 @@ def run_sender(
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> None:
"""Run the sender (blocking).
@@ -351,22 +358,26 @@ def run_sender(
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name (not used in SENDER mode)
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
"""
sender = create_sender(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
)
# Set up signal handlers
@@ -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 "",
}
)
+54 -6
View File
@@ -23,24 +23,72 @@ async def members_page(request: Request) -> HTMLResponse:
def node_sort_key(node: dict) -> int:
"""Sort nodes: repeater first, then chat, then others."""
role = (node.get("node_role") or "").lower()
if role == "repeater":
adv_type = (node.get("adv_type") or "").lower()
if adv_type == "repeater":
return 0
if role == "chat":
if adv_type == "chat":
return 1
return 2
try:
# Fetch all members
response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if response.status_code == 200:
data = response.json()
members = data.get("items", [])
# Sort nodes within each member (repeater first, then chat)
# Fetch all nodes with member_id tags in one query
nodes_response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"has_tag": "member_id", "limit": 500}
)
# Build a map of member_id -> nodes
member_nodes_map: dict[str, list] = {}
if nodes_response.status_code == 200:
nodes_data = nodes_response.json()
all_nodes = nodes_data.get("items", [])
for node in all_nodes:
# Find member_id tag
for tag in node.get("tags", []):
if tag.get("key") == "member_id":
member_id_value = tag.get("value")
if member_id_value:
if member_id_value not in member_nodes_map:
member_nodes_map[member_id_value] = []
member_nodes_map[member_id_value].append(node)
break
# Assign nodes to members and sort
for member in members:
if member.get("nodes"):
member["nodes"] = sorted(member["nodes"], key=node_sort_key)
member_id = member.get("member_id")
if member_id and member_id in member_nodes_map:
# Sort nodes (repeater first, then chat, then by name tag)
nodes = member_nodes_map[member_id]
# Sort by advertisement type first, then by name
def full_sort_key(node: dict) -> tuple:
adv_type = (node.get("adv_type") or "").lower()
type_priority = (
0
if adv_type == "repeater"
else (1 if adv_type == "chat" else 2)
)
# Get name from tags
node_name = node.get("name") or ""
for tag in node.get("tags", []):
if tag.get("key") == "name":
node_name = tag.get("value") or node_name
break
return (type_priority, node_name.lower())
member["nodes"] = sorted(nodes, key=full_sort_key)
else:
member["nodes"] = []
except Exception as e:
logger.warning(f"Failed to fetch members from API: {e}")
context["api_error"] = str(e)
@@ -23,11 +23,11 @@
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Public Key</span>
<span class="label-text">Search</span>
</label>
<input type="text" name="public_key" value="{{ public_key }}" placeholder="Filter by public key..." class="input input-bordered input-sm w-80" />
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<button type="submit" class="btn btn-primary btn-sm">Search</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
@@ -49,8 +49,8 @@
<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>
{% 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>
@@ -80,7 +80,7 @@
{% 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] + '...' }}
{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}
</a>
</li>
{% endfor %}
@@ -88,8 +88,8 @@
</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>
{% if ad.receivers[0].tag_name or ad.receivers[0].name %}
<div class="font-medium">{{ ad.receivers[0].tag_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>
@@ -97,8 +97,8 @@
</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>
{% if ad.receiver_tag_name or ad.receiver_name %}
<div class="font-medium">{{ ad.receiver_tag_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>
@@ -126,7 +126,7 @@
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
<a href="?page={{ page - 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
@@ -135,14 +135,14 @@
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
<a href="?page={{ p }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
<a href="?page={{ page + 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
+2 -2
View File
@@ -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,7 +114,7 @@
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">Powered by <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>
+14 -26
View File
@@ -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,
+13 -9
View File
@@ -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 %}
+7 -7
View File
@@ -78,8 +78,8 @@
{% 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 %}
@@ -96,7 +96,7 @@
{% 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>
<span class="flex-1">{{ recv.tag_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 %}
@@ -107,8 +107,8 @@
</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>
{% if msg.receivers[0].tag_name or msg.receivers[0].name %}
<div class="font-medium">{{ msg.receivers[0].tag_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>
@@ -116,8 +116,8 @@
</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>
{% if msg.receiver_tag_name or msg.receiver_name %}
<div class="font-medium">{{ msg.receiver_tag_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>
+64 -46
View File
@@ -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,
+148 -37
View File
@@ -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: '&copy; <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 %}
+6 -6
View File
@@ -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">
@@ -57,17 +57,17 @@
</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>
{% 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>
+71 -6
View File
@@ -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()
+29
View File
@@ -1,8 +1,11 @@
"""Fixtures for collector component tests."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.base import Base
@pytest.fixture
@@ -20,3 +23,29 @@ def db_session(db_manager):
session = db_manager.get_session()
yield session
session.close()
@pytest.fixture
async def async_db_session():
"""Create an async database session for testing.
Uses a separate in-memory database with tables created inline.
"""
# Create async engine with in-memory database
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create session factory
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
# Provide session
async with async_session_maker() as session:
yield session
# Cleanup
await engine.dispose()
+250
View File
@@ -0,0 +1,250 @@
"""Tests for data cleanup functionality."""
import pytest
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from meshcore_hub.collector.cleanup import cleanup_old_data, CleanupStats
from meshcore_hub.common.models import (
Advertisement,
EventLog,
Message,
Node,
Telemetry,
TracePath,
)
@pytest.mark.asyncio
async def test_cleanup_old_data_dry_run(async_db_session: AsyncSession) -> None:
"""Test cleanup in dry-run mode."""
# Create test node
node = Node(
public_key="a" * 64,
name="Test Node",
)
async_db_session.add(node)
await async_db_session.flush()
# Create old advertisement (60 days ago)
old_date = datetime.now(timezone.utc) - timedelta(days=60)
old_adv = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_adv)
# Create recent advertisement (10 days ago)
recent_date = datetime.now(timezone.utc) - timedelta(days=10)
recent_adv = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=recent_date,
updated_at=recent_date,
)
async_db_session.add(recent_adv)
await async_db_session.commit()
# Run cleanup in dry-run mode with 30-day retention
stats = await cleanup_old_data(async_db_session, retention_days=30, dry_run=True)
# Should report 1 advertisement would be deleted
assert stats.advertisements_deleted == 1
assert stats.total_deleted == 1
# Verify no data was actually deleted
await async_db_session.rollback() # Refresh from DB
from sqlalchemy import select, func
count = await async_db_session.scalar(
select(func.count()).select_from(Advertisement)
)
assert count == 2 # Both still exist
@pytest.mark.asyncio
async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
"""Test cleanup in live mode."""
# Create test node
node = Node(
public_key="b" * 64,
name="Test Node",
)
async_db_session.add(node)
await async_db_session.flush()
# Create old records (60 days ago)
old_date = datetime.now(timezone.utc) - timedelta(days=60)
old_adv = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_adv)
old_msg = Message(
receiver_node_id=node.id,
message_type="channel",
text="old message",
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_msg)
old_telemetry = Telemetry(
receiver_node_id=node.id,
node_id=node.id,
node_public_key=node.public_key,
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_telemetry)
old_trace = TracePath(
receiver_node_id=node.id,
initiator_tag="test",
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_trace)
old_event = EventLog(
receiver_node_id=node.id,
event_type="test_event",
created_at=old_date,
updated_at=old_date,
)
async_db_session.add(old_event)
# Create recent records (10 days ago)
recent_date = datetime.now(timezone.utc) - timedelta(days=10)
recent_adv = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=recent_date,
updated_at=recent_date,
)
async_db_session.add(recent_adv)
await async_db_session.commit()
# Run cleanup with 30-day retention
stats = await cleanup_old_data(async_db_session, retention_days=30, dry_run=False)
# Verify statistics
assert stats.advertisements_deleted == 1
assert stats.messages_deleted == 1
assert stats.telemetry_deleted == 1
assert stats.trace_paths_deleted == 1
assert stats.event_logs_deleted == 1
assert stats.total_deleted == 5
# Verify old data was deleted
from sqlalchemy import select, func
adv_count = await async_db_session.scalar(
select(func.count()).select_from(Advertisement)
)
assert adv_count == 1 # Only recent one remains
msg_count = await async_db_session.scalar(select(func.count()).select_from(Message))
assert msg_count == 0 # Old one deleted
# Verify node still exists
from sqlalchemy import select
node_result = await async_db_session.scalar(select(Node).where(Node.id == node.id))
assert node_result is not None
@pytest.mark.asyncio
async def test_cleanup_respects_retention_period(
async_db_session: AsyncSession,
) -> None:
"""Test that cleanup respects the retention period."""
# Create test node
node = Node(
public_key="d" * 64,
name="Test Node",
)
async_db_session.add(node)
await async_db_session.flush()
# Create advertisements at different ages
now = datetime.now(timezone.utc)
# 90 days old - should be deleted with 30-day retention
very_old = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=now - timedelta(days=90),
updated_at=now - timedelta(days=90),
)
async_db_session.add(very_old)
# 40 days old - should be deleted with 30-day retention
old = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=now - timedelta(days=40),
updated_at=now - timedelta(days=40),
)
async_db_session.add(old)
# 20 days old - should be kept
recent = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=now - timedelta(days=20),
updated_at=now - timedelta(days=20),
)
async_db_session.add(recent)
# 5 days old - should be kept
very_recent = Advertisement(
node_id=node.id,
public_key=node.public_key,
created_at=now - timedelta(days=5),
updated_at=now - timedelta(days=5),
)
async_db_session.add(very_recent)
await async_db_session.commit()
# Run cleanup with 30-day retention
stats = await cleanup_old_data(async_db_session, retention_days=30, dry_run=False)
# Should delete the 2 old ones, keep the 2 recent ones
assert stats.advertisements_deleted == 2
assert stats.total_deleted == 2
# Verify count
from sqlalchemy import select, func
adv_count = await async_db_session.scalar(
select(func.count()).select_from(Advertisement)
)
assert adv_count == 2
@pytest.mark.asyncio
async def test_cleanup_stats_repr() -> None:
"""Test CleanupStats string representation."""
stats = CleanupStats()
stats.advertisements_deleted = 10
stats.messages_deleted = 5
stats.telemetry_deleted = 3
stats.trace_paths_deleted = 2
stats.event_logs_deleted = 1
stats.total_deleted = 21
repr_str = repr(stats)
assert "total=21" in repr_str
assert "advertisements=10" in repr_str
assert "messages=5" in repr_str
@@ -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
+61
View File
@@ -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()
+50
View File
@@ -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."""
+43 -12
View File
@@ -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
+2 -1
View File
@@ -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