mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d99262401 | ||
|
|
adfe5bc503 | ||
|
|
deaab9b9de | ||
|
|
95636ef580 | ||
|
|
5831592f88 | ||
|
|
bc7bff8b82 | ||
|
|
9445d2150c | ||
|
|
3e9f478a65 | ||
|
|
6656bd8214 | ||
|
|
0f50bf4a41 | ||
|
|
99206f7467 | ||
|
|
3a89daa9c0 | ||
|
|
86c5ff8f1c | ||
|
|
59d0edc96f | ||
|
|
b01611e0e8 | ||
|
|
1e077f50f7 | ||
|
|
09146a2e94 | ||
|
|
56487597b7 | ||
|
|
de968f397d | ||
|
|
3ca5284c11 | ||
|
|
75d7e5bdfa | ||
|
|
927fcd6efb | ||
|
|
3132d296bb | ||
|
|
96e4215c29 | ||
|
|
fd3c3171ce | ||
|
|
345ffd219b | ||
|
|
9661b22390 | ||
|
|
31aa48c9a0 | ||
|
|
1a3649b3be | ||
|
|
33649a065b | ||
|
|
fd582bda35 | ||
|
|
c42b26c8f3 | ||
|
|
d52163949a | ||
|
|
ca101583f0 | ||
|
|
4af0f2ea80 | ||
|
|
0b3ac64845 | ||
|
|
3c7a8981ee | ||
|
|
238e28ae41 | ||
|
|
68d5049963 | ||
|
|
624fa458ac | ||
|
|
309d575fc0 | ||
|
|
f7b4df13a7 | ||
|
|
13bae5c8d7 | ||
|
|
8a6b4d8e88 | ||
|
|
b67e1b5b2b | ||
|
|
d4e3dc0399 | ||
|
|
7f0adfa6a7 | ||
|
|
94b03b49d9 | ||
|
|
20d75fe041 | ||
|
|
307f3935e0 | ||
|
|
6901bafb02 | ||
|
|
e595dc2b27 | ||
|
|
ed2cf09ff3 | ||
|
|
bec736a894 | ||
|
|
1457360703 | ||
|
|
d8a0f2abb8 | ||
|
|
367f838371 | ||
|
|
741dd3ce84 | ||
|
|
0a12f389df | ||
|
|
8240c2fd57 | ||
|
|
38f7fe291e | ||
|
|
e4087efbf0 | ||
|
|
3051984fb9 | ||
|
|
eea2c90ea4 | ||
|
|
d52c23fc29 | ||
|
|
a1fb71ce65 | ||
|
|
6a5549081f | ||
|
|
68e24ee886 | ||
|
|
61d6b6287e | ||
|
|
7007c84577 | ||
|
|
fd928d9fea | ||
|
|
68b6aa85cd | ||
|
|
abbc07edb3 | ||
|
|
b42add310e | ||
|
|
98a5526e80 | ||
|
|
db86b3198e | ||
|
|
cd4f0b91dc | ||
|
|
a290db0491 | ||
|
|
92b0b883e6 | ||
|
|
9e621c0029 | ||
|
|
a251f3a09f | ||
|
|
0fdedfe5ba | ||
|
|
243a3e8521 | ||
|
|
b24a6f0894 | ||
|
|
57f51c741c | ||
|
|
65b8418af4 | ||
|
|
89ceee8741 | ||
|
|
64ec1a7135 | ||
|
|
3d632a94b1 | ||
|
|
fbd29ff78e |
198
.env.example
198
.env.example
@@ -1,17 +1,40 @@
|
||||
# MeshCore Hub - Docker Compose Environment Configuration
|
||||
# MeshCore Hub - Environment Configuration
|
||||
# Copy this file to .env and customize values
|
||||
#
|
||||
# Configuration is grouped by service. Most deployments only need:
|
||||
# - Common Settings (always required)
|
||||
# - MQTT Settings (always required)
|
||||
# - Interface Settings (for receiver/sender services)
|
||||
#
|
||||
# The Collector, API, and Web services typically run as a combined "core"
|
||||
# profile and share the same data directory.
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# QUICK START: Receiver/Sender Only
|
||||
# -----------------------------------------------------------------------------
|
||||
# For a minimal receiver or sender setup, you only need these settings:
|
||||
#
|
||||
# MQTT_HOST=your-mqtt-broker.example.com
|
||||
# MQTT_PORT=1883
|
||||
# MQTT_USERNAME=your_username
|
||||
# MQTT_PASSWORD=your_password
|
||||
# MQTT_TLS=false
|
||||
# SERIAL_PORT=/dev/ttyUSB0
|
||||
#
|
||||
# Serial ports are typically /dev/ttyUSB[0-9] or /dev/ttyACM[0-9] on Linux.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ===================
|
||||
# Docker Image
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# COMMON SETTINGS
|
||||
# =============================================================================
|
||||
# These settings apply to all services
|
||||
|
||||
# Docker image version tag to use
|
||||
# Options: latest, main, v1.0.0, etc.
|
||||
IMAGE_VERSION=latest
|
||||
|
||||
# ===================
|
||||
# Data & Seed Directories
|
||||
# ===================
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Base directory for runtime data (database, etc.)
|
||||
# Default: ./data (relative to docker-compose.yml location)
|
||||
@@ -19,7 +42,8 @@ IMAGE_VERSION=latest
|
||||
#
|
||||
# Structure:
|
||||
# ${DATA_HOME}/
|
||||
# └── meshcore.db # SQLite database
|
||||
# └── collector/
|
||||
# └── meshcore.db # SQLite database
|
||||
DATA_HOME=./data
|
||||
|
||||
# Directory containing seed data files for import
|
||||
@@ -32,43 +56,43 @@ DATA_HOME=./data
|
||||
# └── members.yaml # Network members for import
|
||||
SEED_HOME=./seed
|
||||
|
||||
# ===================
|
||||
# Common Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# MQTT SETTINGS
|
||||
# =============================================================================
|
||||
# MQTT broker connection settings for interface, collector, and API services
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ===================
|
||||
# MQTT Settings
|
||||
# ===================
|
||||
|
||||
# MQTT Broker connection (for interface/collector/api services)
|
||||
# When using the local MQTT broker (--profile mqtt), use "mqtt" as host
|
||||
# MQTT Broker host
|
||||
# When using the local MQTT broker (--profile mqtt), use "mqtt"
|
||||
# When using an external broker, set the hostname/IP
|
||||
MQTT_HOST=mqtt
|
||||
|
||||
# MQTT Broker port (default: 1883, or 8883 for TLS)
|
||||
MQTT_PORT=1883
|
||||
|
||||
# MQTT authentication (optional)
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
|
||||
# MQTT topic prefix for all MeshCore messages
|
||||
MQTT_PREFIX=meshcore
|
||||
|
||||
# Enable TLS/SSL for MQTT connection (default: false)
|
||||
# Enable TLS/SSL for MQTT connection
|
||||
# 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
|
||||
|
||||
# ===================
|
||||
# Interface Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# INTERFACE SETTINGS (Receiver/Sender)
|
||||
# =============================================================================
|
||||
# Settings for the MeshCore device interface services
|
||||
|
||||
# Serial port for receiver device
|
||||
SERIAL_PORT=/dev/ttyUSB0
|
||||
|
||||
# Serial port for sender device (if separate)
|
||||
# Serial port for sender device (if using separate device)
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1
|
||||
|
||||
# Baud rate for serial communication
|
||||
@@ -83,55 +107,21 @@ MESHCORE_DEVICE_NAME=
|
||||
NODE_ADDRESS=
|
||||
NODE_ADDRESS_SENDER=
|
||||
|
||||
# ===================
|
||||
# API Settings
|
||||
# ===================
|
||||
# =============================================================================
|
||||
# COLLECTOR SETTINGS
|
||||
# =============================================================================
|
||||
# The collector subscribes to MQTT events and stores them in the database
|
||||
|
||||
# External API port
|
||||
API_PORT=8000
|
||||
|
||||
# API Keys for authentication (generate secure keys for production!)
|
||||
# Example: openssl rand -hex 32
|
||||
API_READ_KEY=
|
||||
API_ADMIN_KEY=
|
||||
|
||||
# ===================
|
||||
# Web Dashboard Settings
|
||||
# ===================
|
||||
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# Network Information (displayed on web dashboard)
|
||||
NETWORK_NAME=MeshCore Network
|
||||
NETWORK_CITY=
|
||||
NETWORK_COUNTRY=
|
||||
|
||||
# Radio configuration (comma-delimited)
|
||||
# Format: <profile>,<frequency>,<bandwidth>,<spreading_factor>,<coding_rate>,<tx_power>
|
||||
# Example: EU/UK Narrow,869.618MHz,62.5kHz,8,8,22dBm
|
||||
NETWORK_RADIO_CONFIG=
|
||||
|
||||
# Contact information
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
|
||||
# Welcome text displayed on the homepage (plain text, optional)
|
||||
# If not set, a default welcome message is shown
|
||||
NETWORK_WELCOME_TEXT=
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Webhook Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Webhooks forward mesh events to external HTTP endpoints as POST requests
|
||||
|
||||
# Webhook for advertisement events (node discovery)
|
||||
# Events are sent as POST requests with JSON payload
|
||||
WEBHOOK_ADVERTISEMENT_URL=
|
||||
WEBHOOK_ADVERTISEMENT_SECRET=
|
||||
|
||||
# Webhook for all message events (channel and direct messages)
|
||||
# Use this for a single endpoint handling all messages
|
||||
WEBHOOK_MESSAGE_URL=
|
||||
WEBHOOK_MESSAGE_SECRET=
|
||||
|
||||
@@ -147,34 +137,82 @@ WEBHOOK_TIMEOUT=10.0
|
||||
WEBHOOK_MAX_RETRIES=3
|
||||
WEBHOOK_RETRY_BACKOFF=2.0
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Data Retention Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Automatic cleanup of old event data (advertisements, messages, telemetry, etc.)
|
||||
|
||||
# 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.)
|
||||
# Number of days to retain event data
|
||||
# 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)
|
||||
# Hours between automatic cleanup runs
|
||||
# Applies to both event data and node cleanup
|
||||
DATA_RETENTION_INTERVAL_HOURS=24
|
||||
|
||||
# ===================
|
||||
# -------------------
|
||||
# Node Cleanup Settings
|
||||
# ===================
|
||||
# -------------------
|
||||
# Automatic removal of inactive nodes
|
||||
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# API SETTINGS
|
||||
# =============================================================================
|
||||
# REST API for querying data and sending commands
|
||||
|
||||
# External API port
|
||||
API_PORT=8000
|
||||
|
||||
# API Keys for authentication
|
||||
# Generate secure keys for production: openssl rand -hex 32
|
||||
# Leave empty to disable authentication (not recommended for production)
|
||||
API_READ_KEY=
|
||||
API_ADMIN_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# WEB DASHBOARD SETTINGS
|
||||
# =============================================================================
|
||||
# Web interface for visualizing network status
|
||||
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# -------------------
|
||||
# Network Information
|
||||
# -------------------
|
||||
# Displayed on the web dashboard homepage
|
||||
|
||||
# Network display name
|
||||
NETWORK_NAME=MeshCore Network
|
||||
|
||||
# Network location
|
||||
NETWORK_CITY=
|
||||
NETWORK_COUNTRY=
|
||||
|
||||
# Radio configuration (comma-delimited)
|
||||
# Format: <profile>,<frequency>,<bandwidth>,<spreading_factor>,<coding_rate>,<tx_power>
|
||||
# Example: EU/UK Narrow,869.618MHz,62.5kHz,SF8,CR8,22dBm
|
||||
NETWORK_RADIO_CONFIG=
|
||||
|
||||
# Welcome text displayed on the homepage (optional, plain text)
|
||||
# If not set, a default welcome message is shown
|
||||
NETWORK_WELCOME_TEXT=
|
||||
|
||||
# -------------------
|
||||
# Contact Information
|
||||
# -------------------
|
||||
# Contact links displayed in the footer
|
||||
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -16,22 +16,10 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black flake8 mypy
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Check formatting with black
|
||||
run: black --check src/ tests/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: flake8 src/ tests/
|
||||
|
||||
- name: Type check with mypy
|
||||
run: mypy src/
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
@@ -39,7 +27,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11"]
|
||||
python-version: ["3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -60,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
if: matrix.python-version == '3.11'
|
||||
if: matrix.python-version == '3.13'
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
@@ -76,7 +64,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
|
||||
49
.github/workflows/claude.yml
vendored
49
.github/workflows/claude.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# 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:*)'
|
||||
@@ -14,7 +14,7 @@ repos:
|
||||
rev: 24.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.11
|
||||
language_version: python3.13
|
||||
args: ["--line-length=88"]
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.11
|
||||
3.13
|
||||
|
||||
92
AGENTS.md
92
AGENTS.md
@@ -18,7 +18,7 @@ This document provides context and guidelines for AI coding assistants working o
|
||||
|
||||
## Project Overview
|
||||
|
||||
MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components:
|
||||
MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components:
|
||||
|
||||
- **meshcore_interface**: Serial/USB interface to MeshCore companion nodes, publishes/subscribes to MQTT
|
||||
- **meshcore_collector**: Collects MeshCore events from MQTT and stores them in a database
|
||||
@@ -37,7 +37,7 @@ MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Language | Python 3.11+ |
|
||||
| Language | Python 3.13+ |
|
||||
| Package Management | pip with pyproject.toml |
|
||||
| CLI Framework | Click |
|
||||
| Configuration | Pydantic Settings |
|
||||
@@ -274,10 +274,9 @@ meshcore-hub/
|
||||
│ │ ├── app.py # FastAPI app
|
||||
│ │ ├── auth.py # Authentication
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── routes/ # API routes
|
||||
│ │ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ │ └── ...
|
||||
│ │ └── templates/ # Dashboard HTML
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ └── ...
|
||||
│ └── web/
|
||||
│ ├── cli.py
|
||||
│ ├── app.py # FastAPI app
|
||||
@@ -456,11 +455,15 @@ See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list.
|
||||
Key variables:
|
||||
- `DATA_HOME` - Base directory for runtime data (default: `./data`)
|
||||
- `SEED_HOME` - Directory containing seed data files (default: `./seed`)
|
||||
- `PAGES_HOME` - Directory containing custom markdown pages (default: `./pages`)
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `DATABASE_URL` - SQLAlchemy database URL (default: `sqlite:///{DATA_HOME}/collector/meshcore.db`)
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
**Seed Data (`SEED_HOME`)** - Contains initial data files for database seeding:
|
||||
@@ -470,6 +473,27 @@ ${SEED_HOME}/
|
||||
└── members.yaml # Network members list
|
||||
```
|
||||
|
||||
**Custom Pages (`PAGES_HOME`)** - Contains custom markdown pages for the web dashboard:
|
||||
```
|
||||
${PAGES_HOME}/
|
||||
├── about.md # Example: About page (/pages/about)
|
||||
├── faq.md # Example: FAQ page (/pages/faq)
|
||||
└── getting-started.md # Example: Getting Started (/pages/getting-started)
|
||||
```
|
||||
|
||||
Pages use YAML frontmatter for metadata:
|
||||
```markdown
|
||||
---
|
||||
title: About Us # Browser tab title and nav link (not rendered on page)
|
||||
slug: about # URL path (default: filename without .md)
|
||||
menu_order: 10 # Nav sort order (default: 100, lower = earlier)
|
||||
---
|
||||
|
||||
# About Our Network
|
||||
|
||||
Markdown content here (include your own heading)...
|
||||
```
|
||||
|
||||
**Runtime Data (`DATA_HOME`)** - Contains runtime data (gitignored):
|
||||
```
|
||||
${DATA_HOME}/
|
||||
@@ -479,13 +503,23 @@ ${DATA_HOME}/
|
||||
|
||||
Services automatically create their subdirectories if they don't exist.
|
||||
|
||||
### Automatic Seeding
|
||||
### Seeding
|
||||
|
||||
The collector automatically imports seed data on startup if YAML files exist in `SEED_HOME`:
|
||||
The database can be seeded with node tags and network members from YAML files in `SEED_HOME`:
|
||||
- `node_tags.yaml` - Node tag definitions (keyed by public_key)
|
||||
- `members.yaml` - Network member definitions
|
||||
|
||||
Manual seeding can be triggered with: `meshcore-hub collector seed`
|
||||
**Important:** Seeding is NOT automatic and must be run explicitly. This prevents seed files from overwriting user changes made via the admin UI.
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
**Note:** Once the admin UI is enabled (`WEB_ADMIN_ENABLED=true`), tags should be managed through the web interface rather than seed files.
|
||||
|
||||
### Webhook Configuration
|
||||
|
||||
@@ -536,6 +570,22 @@ When enabled, the collector automatically removes nodes where:
|
||||
|
||||
**Note:** Both event data and node cleanup run on the same schedule (DATA_RETENTION_INTERVAL_HOURS).
|
||||
|
||||
**Contact Cleanup (Interface RECEIVER):**
|
||||
|
||||
The interface RECEIVER mode can automatically remove stale contacts from the MeshCore companion node's contact database. This prevents the companion node from resyncing old/dead contacts back to the collector, freeing up memory on the device (typically limited to ~100 contacts).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CONTACT_CLEANUP_ENABLED` | Enable automatic removal of stale contacts (default: true) |
|
||||
| `CONTACT_CLEANUP_DAYS` | Remove contacts not advertised for this many days (default: 7) |
|
||||
|
||||
When enabled, during each contact sync the receiver checks each contact's `last_advert` timestamp:
|
||||
- Contacts with `last_advert` older than `CONTACT_CLEANUP_DAYS` are removed from the device
|
||||
- Stale contacts are not published to MQTT (preventing collector database pollution)
|
||||
- Contacts without a `last_advert` timestamp are preserved (no removal without data)
|
||||
|
||||
This cleanup runs automatically whenever the receiver syncs contacts (on startup and after each advertisement event).
|
||||
|
||||
Manual cleanup can be triggered at any time with:
|
||||
```bash
|
||||
# Dry run to see what would be deleted
|
||||
@@ -559,9 +609,13 @@ Webhook payload structure:
|
||||
### Common Issues
|
||||
|
||||
1. **MQTT Connection Failed**: Check broker is running and `MQTT_HOST`/`MQTT_PORT` are correct
|
||||
2. **Database Migration Errors**: Ensure `DATABASE_URL` is correct, run `alembic upgrade head`
|
||||
2. **Database Migration Errors**: Ensure `DATA_HOME` is writable, run `meshcore-hub db upgrade`
|
||||
3. **Import Errors**: Ensure package is installed with `pip install -e .`
|
||||
4. **Type Errors**: Run `mypy src/` to check type annotations
|
||||
4. **Type Errors**: Run `pre-commit run --all-files` to check type annotations and other issues
|
||||
5. **NixOS greenlet errors**: On NixOS, the pre-built greenlet wheel may fail with `libstdc++.so.6` errors. Rebuild from source:
|
||||
```bash
|
||||
pip install --no-binary greenlet greenlet
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -624,6 +678,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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Builder - Install dependencies and build package
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -39,7 +39,7 @@ RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/mes
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime - Final production image
|
||||
# =============================================================================
|
||||
FROM python:3.11-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="MeshCore Hub" \
|
||||
|
||||
447
README.md
447
README.md
@@ -1,6 +1,6 @@
|
||||
# MeshCore Hub
|
||||
|
||||
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
@@ -17,41 +17,45 @@ MeshCore Hub provides a complete solution for monitoring, collecting, and intera
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MeshCore │ │ MeshCore │ │ MeshCore │
|
||||
│ Device 1 │ │ Device 2 │ │ Device 3 │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ Serial/USB │ Serial/USB │ Serial/USB
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Interface │ │ Interface │ │ Interface │
|
||||
│ (RECEIVER) │ │ (RECEIVER) │ │ (SENDER) │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────▲────────┘
|
||||
│ │ │
|
||||
│ Publish │ Publish │ Subscribe
|
||||
│ │ │
|
||||
└───────────┬───────────┴───────────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ MQTT │
|
||||
│ Broker │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Collector │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Database │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌───────▼───────┐
|
||||
│ API │◄──────│ Web Dashboard │
|
||||
└─────────────┘ └───────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Devices["MeshCore Devices"]
|
||||
D1["Device 1"]
|
||||
D2["Device 2"]
|
||||
D3["Device 3"]
|
||||
end
|
||||
|
||||
subgraph Interfaces["Interface Layer"]
|
||||
I1["RECEIVER"]
|
||||
I2["RECEIVER"]
|
||||
I3["SENDER"]
|
||||
end
|
||||
|
||||
D1 -->|Serial| I1
|
||||
D2 -->|Serial| I2
|
||||
D3 -->|Serial| I3
|
||||
|
||||
I1 -->|Publish| MQTT
|
||||
I2 -->|Publish| MQTT
|
||||
MQTT -->|Subscribe| I3
|
||||
|
||||
MQTT["MQTT Broker"]
|
||||
|
||||
subgraph Backend["Backend Services"]
|
||||
Collector --> Database --> API
|
||||
end
|
||||
|
||||
MQTT --> Collector
|
||||
API --> Web["Web Dashboard"]
|
||||
|
||||
style Devices fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Interfaces fill:none,stroke:#f57c00,stroke-width:2px
|
||||
style Backend fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style Database fill:none,stroke:#c2185b,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
## Features
|
||||
@@ -76,9 +80,13 @@ The quickest way to get started is running the entire stack on a single machine
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
# Create a directory, download the Docker Compose file and
|
||||
# example environment configuration file
|
||||
|
||||
mkdir meshcore-hub
|
||||
cd meshcore-hub
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/.env.example
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
@@ -97,33 +105,34 @@ This starts all services: MQTT broker, collector, API, web dashboard, and the in
|
||||
|
||||
For larger deployments, you can separate receiver nodes from the central infrastructure. This allows multiple community members to contribute receiver coverage while hosting the backend centrally.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community Members │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Raspberry Pi │ │ Raspberry Pi │ │ Any Linux │ │
|
||||
│ │ + MeshCore │ │ + MeshCore │ │ + MeshCore │ │
|
||||
│ │ Device │ │ Device │ │ Device │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ receiver profile only │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ │ │
|
||||
│ MQTT (port 1883) │
|
||||
│ │ │
|
||||
└────────────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community VPS / Server │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ MQTT │──▶│ Collector │──▶│ API │◀──│ Web Dashboard│ │
|
||||
│ │ Broker │ │ │ │ │ │ (public) │ │
|
||||
│ └──────────┘ └───────────┘ └─────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Community["Community Members"]
|
||||
R1["Raspberry Pi + MeshCore"]
|
||||
R2["Raspberry Pi + MeshCore"]
|
||||
R3["Any Linux + MeshCore"]
|
||||
end
|
||||
|
||||
subgraph Server["Community VPS / Server"]
|
||||
MQTT["MQTT Broker"]
|
||||
Collector
|
||||
API
|
||||
Web["Web Dashboard (public)"]
|
||||
|
||||
MQTT --> Collector --> API
|
||||
API <--- Web
|
||||
end
|
||||
|
||||
R1 -->|MQTT port 1883| MQTT
|
||||
R2 -->|MQTT port 1883| MQTT
|
||||
R3 -->|MQTT port 1883| MQTT
|
||||
|
||||
style Community fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Server fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
**On each receiver node (Raspberry Pi, etc.):**
|
||||
@@ -151,9 +160,9 @@ This architecture allows:
|
||||
- Community members to contribute coverage with minimal setup
|
||||
- The central server to be hosted anywhere with internet access
|
||||
|
||||
## Quick Start
|
||||
## Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Docker Compose Profiles
|
||||
|
||||
Docker Compose uses **profiles** to select which services to run:
|
||||
|
||||
@@ -170,14 +179,6 @@ Docker Compose uses **profiles** to select which services to run:
|
||||
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
cd meshcore-hub
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings (API keys, serial port, network info)
|
||||
|
||||
# Create database schema
|
||||
docker compose --profile migrate run --rm db-migrate
|
||||
|
||||
@@ -200,7 +201,7 @@ docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
#### Serial Device Access
|
||||
### Serial Device Access
|
||||
|
||||
For production with real MeshCore devices, ensure the serial port is accessible:
|
||||
|
||||
@@ -216,13 +217,25 @@ SERIAL_PORT=/dev/ttyUSB0
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
|
||||
```
|
||||
|
||||
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
|
||||
|
||||
```bash
|
||||
# List available devices by ID
|
||||
ls -la /dev/serial/by-id/
|
||||
|
||||
# Example output:
|
||||
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
|
||||
|
||||
# Configure using the stable ID
|
||||
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the package
|
||||
pip install -e ".[dev]"
|
||||
@@ -237,57 +250,6 @@ 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:
|
||||
@@ -297,28 +259,24 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `DATA_HOME` | `./data` | Base directory for runtime data |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files |
|
||||
| `MQTT_HOST` | `localhost` | MQTT broker hostname |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `MQTT_USERNAME` | *(none)* | MQTT username (optional) |
|
||||
| `MQTT_PASSWORD` | *(none)* | MQTT password (optional) |
|
||||
| `MQTT_PREFIX` | `meshcore` | Topic prefix for all MQTT messages |
|
||||
| `MQTT_TLS` | `false` | Enable TLS/SSL for MQTT connection |
|
||||
|
||||
### Interface Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite:///{data_home}/collector/meshcore.db` | SQLAlchemy database URL |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files (node_tags.yaml, members.yaml) |
|
||||
|
||||
#### Webhook Configuration
|
||||
### Webhooks
|
||||
|
||||
The collector can forward certain events to external HTTP endpoints:
|
||||
|
||||
@@ -343,6 +301,18 @@ Webhook payload format:
|
||||
}
|
||||
```
|
||||
|
||||
### Data Retention
|
||||
|
||||
The collector automatically cleans up old event data and inactive nodes:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATA_RETENTION_ENABLED` | `true` | Enable automatic cleanup of old events |
|
||||
| `DATA_RETENTION_DAYS` | `30` | Days to retain event data |
|
||||
| `DATA_RETENTION_INTERVAL_HOURS` | `24` | Hours between cleanup runs |
|
||||
| `NODE_CLEANUP_ENABLED` | `true` | Enable removal of inactive nodes |
|
||||
| `NODE_CLEANUP_DAYS` | `7` | Remove nodes not seen for this many days |
|
||||
|
||||
### API Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -359,62 +329,82 @@ Webhook payload format:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
| `NETWORK_RADIO_CONFIG` | *(none)* | Radio config (comma-delimited: profile,freq,bw,sf,cr,power) |
|
||||
| `NETWORK_WELCOME_TEXT` | *(none)* | Custom welcome text for homepage |
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `PAGES_HOME` | `./pages` | Directory containing custom markdown pages |
|
||||
|
||||
## CLI Reference
|
||||
### Custom Pages
|
||||
|
||||
The web dashboard supports custom markdown pages for adding static content like "About Us", "Getting Started", or "FAQ" pages. Pages are stored as markdown files with YAML frontmatter.
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Show help
|
||||
meshcore-hub --help
|
||||
# Create pages directory
|
||||
mkdir -p pages
|
||||
|
||||
# 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
|
||||
# Create a custom page
|
||||
cat > pages/about.md << 'EOF'
|
||||
---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Collector component
|
||||
meshcore-hub collector # Run collector (auto-seeds on startup)
|
||||
meshcore-hub collector seed # Import all seed data from SEED_HOME
|
||||
meshcore-hub collector import-tags # Import node tags from SEED_HOME/node_tags.yaml
|
||||
meshcore-hub collector import-tags /path/to/file.yaml # Import from specific file
|
||||
meshcore-hub collector import-members # Import members from SEED_HOME/members.yaml
|
||||
meshcore-hub collector import-members /path/to/file.yaml # Import from specific file
|
||||
# About Our Network
|
||||
|
||||
# API component
|
||||
meshcore-hub api --host 0.0.0.0 --port 8000
|
||||
Welcome to our MeshCore mesh network!
|
||||
|
||||
# Web dashboard
|
||||
meshcore-hub web --port 8080 --network-name "My Network"
|
||||
## Getting Started
|
||||
|
||||
# Database management
|
||||
meshcore-hub db upgrade # Run migrations
|
||||
meshcore-hub db downgrade # Rollback one migration
|
||||
meshcore-hub db current # Show current revision
|
||||
1. Get a compatible LoRa device
|
||||
2. Flash MeshCore firmware
|
||||
3. Configure your radio settings
|
||||
EOF
|
||||
```
|
||||
|
||||
**Frontmatter fields:**
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `title` | Filename titlecased | Browser tab title and navigation link text (not rendered on page) |
|
||||
| `slug` | Filename without `.md` | URL path (e.g., `about` → `/pages/about`) |
|
||||
| `menu_order` | `100` | Sort order in navigation (lower = earlier) |
|
||||
|
||||
The markdown content is rendered as-is, so include your own `# Heading` if desired.
|
||||
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the pages directory:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (already configured)
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
environment:
|
||||
- PAGES_HOME=/pages
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
|
||||
The collector supports seeding the database with node tags and network members on startup. Seed files are read from the `SEED_HOME` directory (default: `./seed`).
|
||||
The database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
|
||||
### Automatic Seeding
|
||||
#### Running the Seed Process
|
||||
|
||||
When the collector starts, it automatically imports seed data from YAML files if they exist:
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
### Manual Seeding
|
||||
Seeding is a separate process and must be run explicitly:
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
This imports data from the following files (if they exist):
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
#### Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
@@ -428,11 +418,11 @@ data/ # DATA_HOME (runtime data)
|
||||
|
||||
Example seed files are provided in `example/seed/`.
|
||||
|
||||
## Node Tags
|
||||
### Node Tags
|
||||
|
||||
Node tags allow you to attach custom metadata to nodes (e.g., location, role, owner). Tags are stored in the database and returned with node data via the API.
|
||||
|
||||
### Node Tags YAML Format
|
||||
#### Node Tags YAML Format
|
||||
|
||||
Tags are keyed by public key in YAML format:
|
||||
|
||||
@@ -448,53 +438,41 @@ Tags are keyed by public key in YAML format:
|
||||
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
|
||||
friendly_name: Oakland Repeater
|
||||
altitude: 150
|
||||
location:
|
||||
value: "37.8044,-122.2712"
|
||||
type: coordinate
|
||||
```
|
||||
|
||||
Tag values can be:
|
||||
- **YAML primitives** (auto-detected type): strings, numbers, booleans
|
||||
- **Explicit type** (for special types like coordinate):
|
||||
- **Explicit type** (when you need to force a specific type):
|
||||
```yaml
|
||||
location:
|
||||
value: "37.7749,-122.4194"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
```
|
||||
|
||||
Supported types: `string`, `number`, `boolean`, `coordinate`
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
### Import Tags Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/node_tags.yaml)
|
||||
meshcore-hub collector import-tags
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-tags /path/to/node_tags.yaml
|
||||
|
||||
# Skip tags for nodes that don't exist
|
||||
meshcore-hub collector import-tags --no-create-nodes
|
||||
```
|
||||
|
||||
## Network Members
|
||||
### Network Members
|
||||
|
||||
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
|
||||
|
||||
### Members YAML Format
|
||||
#### Members YAML Format
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- name: John Doe
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member entry
|
||||
contact: john@example.com
|
||||
public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
- member_id: walshie86
|
||||
name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
- member_id: craig
|
||||
name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `member_id` | Yes | Unique identifier for the member |
|
||||
| `name` | Yes | Member's display name |
|
||||
| `callsign` | No | Amateur radio callsign |
|
||||
| `role` | No | Member's role in the network |
|
||||
@@ -502,44 +480,6 @@ members:
|
||||
| `contact` | No | Contact information |
|
||||
| `public_key` | No | Associated node public key (64-char hex) |
|
||||
|
||||
### Import Members Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/members.yaml)
|
||||
meshcore-hub collector import-members
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-members /path/to/members.yaml
|
||||
```
|
||||
|
||||
### Managing Tags via API
|
||||
|
||||
Tags can also be managed via the REST API:
|
||||
|
||||
```bash
|
||||
# List tags for a node
|
||||
curl http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Create a tag (requires admin key)
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "location", "value": "Building A"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Update a tag
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": "Building B"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
|
||||
# Delete a tag
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
When running, the API provides interactive documentation at:
|
||||
@@ -620,14 +560,8 @@ pytest -k "test_list"
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint
|
||||
flake8 src/ tests/
|
||||
|
||||
# Type check
|
||||
mypy src/
|
||||
# Run all code quality checks (formatting, linting, type checking)
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Creating Database Migrations
|
||||
@@ -657,10 +591,13 @@ meshcore-hub/
|
||||
├── alembic/ # Database migrations
|
||||
├── etc/ # Configuration files (mosquitto.conf)
|
||||
├── example/ # Example files for testing
|
||||
│ └── seed/ # Example seed data files
|
||||
│ ├── node_tags.yaml # Example node tags
|
||||
│ └── members.yaml # Example network members
|
||||
│ ├── seed/ # Example seed data files
|
||||
│ │ ├── node_tags.yaml # Example node tags
|
||||
│ │ └── members.yaml # Example network members
|
||||
│ └── pages/ # Example custom pages
|
||||
│ └── about.md # Example about page
|
||||
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
|
||||
├── pages/ # Custom pages directory (PAGES_HOME, optional)
|
||||
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
|
||||
├── Dockerfile # Docker build configuration
|
||||
├── docker-compose.yml # Docker Compose services
|
||||
@@ -684,7 +621,7 @@ meshcore-hub/
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests and linting (`pytest && black . && flake8`)
|
||||
4. Run tests and quality checks (`pytest && pre-commit run --all-files`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add lat lon columns to nodes
|
||||
|
||||
Revision ID: 4e2e787a1660
|
||||
Revises: aa1162502616
|
||||
Create Date: 2026-01-09 20:04:04.273741+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4e2e787a1660"
|
||||
down_revision: Union[str, None] = "aa1162502616"
|
||||
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("nodes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_column("lon")
|
||||
batch_op.drop_column("lat")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- "${MQTT_EXTERNAL_PORT:-1883}:1883"
|
||||
- "${MQTT_WS_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
# - ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_log:/mosquitto/log
|
||||
healthcheck:
|
||||
@@ -138,8 +138,11 @@ services:
|
||||
- all
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
@@ -151,8 +154,6 @@ services:
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Webhook configuration
|
||||
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
|
||||
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
|
||||
@@ -193,13 +194,14 @@ services:
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
collector:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -209,8 +211,6 @@ services:
|
||||
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
- API_HOST=0.0.0.0
|
||||
- API_PORT=8000
|
||||
- API_READ_KEY=${API_READ_KEY:-}
|
||||
@@ -241,12 +241,17 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
# Use ADMIN key to allow write operations from admin interface
|
||||
# Falls back to READ key if ADMIN key is not set
|
||||
- API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
@@ -255,6 +260,7 @@ services:
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- PAGES_HOME=/pages
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
@@ -274,18 +280,20 @@ services:
|
||||
container_name: meshcore-db-migrate
|
||||
profiles:
|
||||
- all
|
||||
- core
|
||||
- migrate
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
command: ["db", "upgrade"]
|
||||
|
||||
# ==========================================================================
|
||||
# Seed Data - Import node_tags.json and members.json from SEED_HOME
|
||||
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
|
||||
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
|
||||
# Since tags are now managed via the admin UI, automatic seeding would
|
||||
# overwrite user changes.
|
||||
# ==========================================================================
|
||||
seed:
|
||||
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
||||
@@ -294,26 +302,24 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: meshcore-seed
|
||||
profiles:
|
||||
- all
|
||||
- seed
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory for database (read-write)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
# Mount seed directory for seed files (read-only)
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed:ro
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Imports both node_tags.json and members.json if they exist
|
||||
# Imports both node_tags.yaml and members.yaml if they exist
|
||||
command: ["collector", "seed"]
|
||||
|
||||
# ==========================================================================
|
||||
# Volumes
|
||||
# ==========================================================================
|
||||
volumes:
|
||||
hub_data:
|
||||
name: meshcore_hub_data
|
||||
mosquitto_data:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 124 KiB |
87
example/pages/join.md
Normal file
87
example/pages/join.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Join
|
||||
slug: join
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Getting Started with MeshCore
|
||||
|
||||
MeshCore is an open-source off-grid LoRa mesh networking platform. This guide will help you get connected to the network.
|
||||
|
||||
For detailed documentation, see the [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md).
|
||||
|
||||
## Node Types
|
||||
|
||||
MeshCore devices operate in different modes:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Companion** | Connects to your phone via Bluetooth. Use this for messaging and interacting with the network. |
|
||||
| **Repeater** | Standalone node that extends network coverage. Place these in elevated locations for best results. |
|
||||
| **Room Server** | Hosts chat rooms that persist messages for offline users. |
|
||||
|
||||
Most users start with a **Companion** node paired to their phone.
|
||||
|
||||
## Frequency Regulations
|
||||
|
||||
MeshCore uses LoRa radio, which operates on unlicensed ISM bands. You **must** use the correct frequency for your region:
|
||||
|
||||
| Region | Frequency | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Europe (EU) | 868 MHz | EU868 band |
|
||||
| United Kingdom | 868 MHz | Same as EU |
|
||||
| North America | 915 MHz | US915 band |
|
||||
| Australia | 915 MHz | AU915 band |
|
||||
|
||||
Using the wrong frequency is illegal and may cause interference. Check your local regulations.
|
||||
|
||||
## Compatible Hardware
|
||||
|
||||
MeshCore runs on inexpensive low-power LoRa devices. Popular options include:
|
||||
|
||||
### Recommended Devices
|
||||
|
||||
| Device | Manufacturer | Features |
|
||||
|--------|--------------|----------|
|
||||
| [Heltec V3](https://heltec.org/project/wifi-lora-32-v3/) | Heltec | Budget-friendly, OLED display |
|
||||
| [T114](https://heltec.org/project/mesh-node-t114/) | Heltec | Compact, GPS, colour display |
|
||||
| [T1000-E](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | Seeed Studio | Credit-card sized, GPS, weatherproof |
|
||||
| [T-Deck Plus](https://www.lilygo.cc/products/t-deck-plus) | LilyGO | Built-in keyboard, touchscreen, GPS |
|
||||
|
||||
Ensure you purchase the correct frequency variant (868MHz for EU/UK, 915MHz for US/AU).
|
||||
|
||||
### Where to Buy
|
||||
|
||||
- **Heltec**: [Official Store](https://heltec.org/) or AliExpress
|
||||
- **LilyGO**: [Official Store](https://lilygo.cc/) or AliExpress
|
||||
- **Seeed Studio**: [Official Store](https://www.seeedstudio.com/)
|
||||
- **Amazon**: Search for device name + "LoRa 868" (or 915 for US)
|
||||
|
||||
## Mobile Apps
|
||||
|
||||
Connect to your Companion node using the official MeshCore apps:
|
||||
|
||||
| Platform | App | Link |
|
||||
|----------|-----|------|
|
||||
| Android | MeshCore | [Google Play](https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android) |
|
||||
| iOS | MeshCore | [App Store](https://apps.apple.com/us/app/meshcore/id6742354151) |
|
||||
|
||||
The app connects via Bluetooth to your Companion node, allowing you to send messages, view the network, and configure your device.
|
||||
|
||||
## Flashing Firmware
|
||||
|
||||
1. Use the [MeshCore Web Flasher](https://flasher.meshcore.co.uk/) for easy browser-based flashing
|
||||
2. Select your device type and region (frequency)
|
||||
3. Connect via USB and flash
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once your device is flashed and paired:
|
||||
|
||||
1. Open the MeshCore app on your phone
|
||||
2. Enable Bluetooth and pair with your device
|
||||
3. Set your node name in the app settings
|
||||
4. Configure your radio settings/profile for your region
|
||||
4. You should start seeing other nodes on the network
|
||||
|
||||
Welcome to the mesh!
|
||||
@@ -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
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
# elevation: 150 # number
|
||||
# is_online: true # boolean
|
||||
#
|
||||
# - Explicit type (for special types like coordinate):
|
||||
# location:
|
||||
# value: "37.7749,-122.4194"
|
||||
# type: coordinate
|
||||
# - Explicit type (when you need to force a specific type):
|
||||
# altitude:
|
||||
# value: "150"
|
||||
# type: number
|
||||
#
|
||||
# Supported types: string, number, boolean, coordinate
|
||||
# Supported types: string, number, boolean
|
||||
|
||||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:
|
||||
friendly_name: Gateway Node
|
||||
|
||||
@@ -8,7 +8,7 @@ version = "0.0.0"
|
||||
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.13"
|
||||
authors = [
|
||||
{name = "MeshCore Hub Contributors"}
|
||||
]
|
||||
@@ -18,8 +18,7 @@ classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
@@ -40,6 +39,8 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"meshcore>=2.2.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -78,7 +79,7 @@ meshcore_hub = ["py.typed"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py311"]
|
||||
target-version = ["py312"]
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
@@ -97,7 +98,7 @@ extend-exclude = '''
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
python_version = "3.13"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
@@ -112,6 +113,8 @@ module = [
|
||||
"uvicorn.*",
|
||||
"alembic.*",
|
||||
"meshcore.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -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,10 +89,16 @@ 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"
|
||||
),
|
||||
member_id: Optional[str] = Query(
|
||||
None, description="Filter by member_id tag value of source node"
|
||||
),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
@@ -118,12 +124,38 @@ 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)
|
||||
|
||||
if received_by:
|
||||
query = query.where(ReceiverNode.public_key == received_by)
|
||||
|
||||
if member_id:
|
||||
# Filter advertisements from nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
@@ -173,11 +205,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 +287,11 @@ async def get_advertisement(
|
||||
data = {
|
||||
"received_by": result.receiver_pk,
|
||||
"receiver_name": result.receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"public_key": adv.public_key,
|
||||
"name": adv.name,
|
||||
"node_name": result.source_name,
|
||||
"node_friendly_name": _get_friendly_name(source_node),
|
||||
"node_tag_name": _get_tag_name(source_node),
|
||||
"adv_type": adv.adv_type or result.source_adv_type,
|
||||
"flags": adv.flags,
|
||||
"received_at": adv.received_at,
|
||||
|
||||
@@ -31,6 +31,7 @@ async def get_stats(
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
|
||||
# Total nodes
|
||||
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
|
||||
@@ -73,6 +74,26 @@ async def get_stats(
|
||||
or 0
|
||||
)
|
||||
|
||||
# Advertisements in last 7 days
|
||||
advertisements_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Advertisement)
|
||||
.where(Advertisement.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Messages in last 7 days
|
||||
messages_7d = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= seven_days_ago)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Recent advertisements (last 10)
|
||||
recent_ads = (
|
||||
session.execute(
|
||||
@@ -82,11 +103,11 @@ async def get_stats(
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get node names, adv_types, and friendly_name tags for the advertised nodes
|
||||
# Get node names, adv_types, and name tags for the advertised nodes
|
||||
ad_public_keys = [ad.public_key for ad in recent_ads]
|
||||
node_names: dict[str, str] = {}
|
||||
node_adv_types: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if ad_public_keys:
|
||||
# Get node names and adv_types from Node table
|
||||
node_query = select(Node.public_key, Node.name, Node.adv_type).where(
|
||||
@@ -98,21 +119,21 @@ async def get_stats(
|
||||
if adv_type:
|
||||
node_adv_types[public_key] = adv_type
|
||||
|
||||
# Get friendly_name tags
|
||||
friendly_name_query = (
|
||||
# Get name tags
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.in_(ad_public_keys))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
tag_names[public_key] = value
|
||||
|
||||
recent_advertisements = [
|
||||
RecentAdvertisement(
|
||||
public_key=ad.public_key,
|
||||
name=ad.name or node_names.get(ad.public_key),
|
||||
friendly_name=friendly_names.get(ad.public_key),
|
||||
tag_name=tag_names.get(ad.public_key),
|
||||
adv_type=ad.adv_type or node_adv_types.get(ad.public_key),
|
||||
received_at=ad.received_at,
|
||||
)
|
||||
@@ -146,7 +167,7 @@ async def get_stats(
|
||||
# Look up sender names for these messages
|
||||
msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix]
|
||||
msg_sender_names: dict[str, str] = {}
|
||||
msg_friendly_names: dict[str, str] = {}
|
||||
msg_tag_names: dict[str, str] = {}
|
||||
if msg_prefixes:
|
||||
for prefix in set(msg_prefixes):
|
||||
sender_node_query = select(Node.public_key, Node.name).where(
|
||||
@@ -156,14 +177,14 @@ async def get_stats(
|
||||
if name:
|
||||
msg_sender_names[public_key[:12]] = name
|
||||
|
||||
sender_friendly_query = (
|
||||
sender_tag_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(sender_friendly_query).all():
|
||||
msg_friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(sender_tag_query).all():
|
||||
msg_tag_names[public_key[:12]] = value
|
||||
|
||||
channel_messages[int(channel_idx)] = [
|
||||
ChannelMessage(
|
||||
@@ -171,8 +192,8 @@ async def get_stats(
|
||||
sender_name=(
|
||||
msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
sender_friendly_name=(
|
||||
msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
sender_tag_name=(
|
||||
msg_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
pubkey_prefix=m.pubkey_prefix,
|
||||
received_at=m.received_at,
|
||||
@@ -185,8 +206,10 @@ async def get_stats(
|
||||
active_nodes=active_nodes,
|
||||
total_messages=total_messages,
|
||||
messages_today=messages_today,
|
||||
messages_7d=messages_7d,
|
||||
total_advertisements=total_advertisements,
|
||||
advertisements_24h=advertisements_24h,
|
||||
advertisements_7d=advertisements_7d,
|
||||
recent_advertisements=recent_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
channel_messages=channel_messages,
|
||||
@@ -205,15 +228,15 @@ async def get_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily advertisement counts for each day in the period
|
||||
Daily advertisement counts for each day in the period (excluding today)
|
||||
"""
|
||||
# Limit to max 90 days
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query advertisement counts grouped by date
|
||||
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
|
||||
@@ -225,6 +248,7 @@ async def get_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Advertisement.received_at >= start_date)
|
||||
.where(Advertisement.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -257,14 +281,14 @@ async def get_message_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily message counts for each day in the period
|
||||
Daily message counts for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query message counts grouped by date
|
||||
date_expr = func.date(Message.received_at)
|
||||
@@ -275,6 +299,7 @@ async def get_message_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Message.received_at >= start_date)
|
||||
.where(Message.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -308,14 +333,14 @@ async def get_node_count_history(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Cumulative node count for each day in the period
|
||||
Cumulative node count for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Get all nodes with their creation dates
|
||||
# Count nodes created on or before each date
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Member, MemberNode, Node
|
||||
from meshcore_hub.common.models import Member
|
||||
from meshcore_hub.common.schemas.members import (
|
||||
MemberCreate,
|
||||
MemberList,
|
||||
MemberNodeRead,
|
||||
MemberRead,
|
||||
MemberUpdate,
|
||||
)
|
||||
@@ -18,50 +16,6 @@ from meshcore_hub.common.schemas.members import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _enrich_member_nodes(
|
||||
member: Member, node_info: dict[str, dict]
|
||||
) -> list[MemberNodeRead]:
|
||||
"""Enrich member nodes with node details from the database.
|
||||
|
||||
Args:
|
||||
member: The member with nodes to enrich
|
||||
node_info: Dict mapping public_key to node details
|
||||
|
||||
Returns:
|
||||
List of MemberNodeRead with node details populated
|
||||
"""
|
||||
enriched_nodes = []
|
||||
for mn in member.nodes:
|
||||
info = node_info.get(mn.public_key, {})
|
||||
enriched_nodes.append(
|
||||
MemberNodeRead(
|
||||
public_key=mn.public_key,
|
||||
node_role=mn.node_role,
|
||||
created_at=mn.created_at,
|
||||
updated_at=mn.updated_at,
|
||||
node_name=info.get("name"),
|
||||
node_adv_type=info.get("adv_type"),
|
||||
friendly_name=info.get("friendly_name"),
|
||||
)
|
||||
)
|
||||
return enriched_nodes
|
||||
|
||||
|
||||
def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead:
|
||||
"""Convert a Member model to MemberRead with enriched node data."""
|
||||
return MemberRead(
|
||||
id=member.id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
description=member.description,
|
||||
contact=member.contact,
|
||||
nodes=_enrich_member_nodes(member, node_info),
|
||||
created_at=member.created_at,
|
||||
updated_at=member.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MemberList)
|
||||
async def list_members(
|
||||
_: RequireRead,
|
||||
@@ -74,45 +28,12 @@ async def list_members(
|
||||
count_query = select(func.count()).select_from(Member)
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Get members with nodes eagerly loaded
|
||||
query = (
|
||||
select(Member)
|
||||
.options(selectinload(Member.nodes))
|
||||
.order_by(Member.name)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
# Get members ordered by name
|
||||
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
|
||||
members = list(session.execute(query).scalars().all())
|
||||
|
||||
# Collect all public keys from member nodes
|
||||
all_public_keys = set()
|
||||
for m in members:
|
||||
for mn in m.nodes:
|
||||
all_public_keys.add(mn.public_key)
|
||||
|
||||
# Fetch node info for all public keys in one query
|
||||
node_info: dict[str, dict] = {}
|
||||
if all_public_keys:
|
||||
node_query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.in_(all_public_keys))
|
||||
)
|
||||
nodes = session.execute(node_query).scalars().all()
|
||||
for node in nodes:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return MemberList(
|
||||
items=[_member_to_read(m, node_info) for m in members],
|
||||
items=[MemberRead.model_validate(m) for m in members],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -126,37 +47,13 @@ async def get_member(
|
||||
member_id: str,
|
||||
) -> MemberRead:
|
||||
"""Get a specific member by ID."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
member = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Fetch node info for member's nodes
|
||||
node_info: dict[str, dict] = {}
|
||||
public_keys = [mn.public_key for mn in member.nodes]
|
||||
if public_keys:
|
||||
node_query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.in_(public_keys))
|
||||
)
|
||||
nodes = session.execute(node_query).scalars().all()
|
||||
for node in nodes:
|
||||
friendly_name = None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
friendly_name = tag.value
|
||||
break
|
||||
node_info[node.public_key] = {
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
|
||||
return _member_to_read(member, node_info)
|
||||
return MemberRead.model_validate(member)
|
||||
|
||||
|
||||
@router.post("", response_model=MemberRead, status_code=201)
|
||||
@@ -166,8 +63,18 @@ async def create_member(
|
||||
member: MemberCreate,
|
||||
) -> MemberRead:
|
||||
"""Create a new member."""
|
||||
# Check if member_id already exists
|
||||
query = select(Member).where(Member.member_id == member.member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
|
||||
# Create member
|
||||
new_member = Member(
|
||||
member_id=member.member_id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
@@ -175,18 +82,6 @@ async def create_member(
|
||||
contact=member.contact,
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member.nodes:
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(new_member)
|
||||
|
||||
@@ -201,15 +96,25 @@ async def update_member(
|
||||
member: MemberUpdate,
|
||||
) -> MemberRead:
|
||||
"""Update a member."""
|
||||
query = (
|
||||
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
|
||||
)
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Update fields
|
||||
if member.member_id is not None:
|
||||
# Check if new member_id is already taken by another member
|
||||
check_query = select(Member).where(
|
||||
Member.member_id == member.member_id, Member.id != member_id
|
||||
)
|
||||
collision = session.execute(check_query).scalar_one_or_none()
|
||||
if collision:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
existing.member_id = member.member_id
|
||||
if member.name is not None:
|
||||
existing.name = member.name
|
||||
if member.callsign is not None:
|
||||
@@ -221,20 +126,6 @@ async def update_member(
|
||||
if member.contact is not None:
|
||||
existing.contact = member.contact
|
||||
|
||||
# Update nodes if provided (replaces existing nodes)
|
||||
if member.nodes is not None:
|
||||
# Clear existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member.nodes:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data.public_key.lower(),
|
||||
node_role=node_data.node_role,
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead, Recei
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract friendly_name tag from a node's tags."""
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
"""Extract name tag from a node's tags."""
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
for tag in node.tags:
|
||||
if tag.key == "friendly_name":
|
||||
if tag.key == "name":
|
||||
return tag.value
|
||||
return None
|
||||
|
||||
@@ -64,17 +64,17 @@ def _fetch_receivers_for_events(
|
||||
# Group by event_hash
|
||||
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
|
||||
|
||||
# Get friendly names for receiver nodes
|
||||
# Get tag names for receiver nodes
|
||||
node_ids = [r.node_id for r in results]
|
||||
friendly_names: dict[str, str] = {}
|
||||
tag_names: dict[str, str] = {}
|
||||
if node_ids:
|
||||
fn_query = (
|
||||
tag_query = (
|
||||
select(NodeTag.node_id, NodeTag.value)
|
||||
.where(NodeTag.node_id.in_(node_ids))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for node_id, value in session.execute(fn_query).all():
|
||||
friendly_names[node_id] = value
|
||||
for node_id, value in session.execute(tag_query).all():
|
||||
tag_names[node_id] = value
|
||||
|
||||
for row in results:
|
||||
if row.event_hash not in receivers_by_hash:
|
||||
@@ -85,7 +85,7 @@ def _fetch_receivers_for_events(
|
||||
node_id=row.node_id,
|
||||
public_key=row.public_key,
|
||||
name=row.name,
|
||||
friendly_name=friendly_names.get(row.node_id),
|
||||
tag_name=tag_names.get(row.node_id),
|
||||
snr=row.snr,
|
||||
received_at=row.received_at,
|
||||
)
|
||||
@@ -153,10 +153,10 @@ async def list_messages(
|
||||
# Execute
|
||||
results = session.execute(query).all()
|
||||
|
||||
# Look up sender names and friendly_names for senders with pubkey_prefix
|
||||
# Look up sender names and tag names for senders with pubkey_prefix
|
||||
pubkey_prefixes = [r[0].pubkey_prefix for r in results if r[0].pubkey_prefix]
|
||||
sender_names: dict[str, str] = {}
|
||||
friendly_names: dict[str, str] = {}
|
||||
sender_tag_names: dict[str, str] = {}
|
||||
if pubkey_prefixes:
|
||||
# Find nodes whose public_key starts with any of these prefixes
|
||||
for prefix in set(pubkey_prefixes):
|
||||
@@ -168,15 +168,15 @@ async def list_messages(
|
||||
if name:
|
||||
sender_names[public_key[:12]] = name
|
||||
|
||||
# Get friendly_name tag
|
||||
friendly_name_query = (
|
||||
# Get name tag
|
||||
tag_name_query = (
|
||||
select(Node.public_key, NodeTag.value)
|
||||
.join(NodeTag, Node.id == NodeTag.node_id)
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.where(NodeTag.key == "friendly_name")
|
||||
.where(NodeTag.key == "name")
|
||||
)
|
||||
for public_key, value in session.execute(friendly_name_query).all():
|
||||
friendly_names[public_key[:12]] = value
|
||||
for public_key, value in session.execute(tag_name_query).all():
|
||||
sender_tag_names[public_key[:12]] = value
|
||||
|
||||
# Collect receiver node IDs to fetch tags
|
||||
receiver_ids = set()
|
||||
@@ -214,14 +214,14 @@ async def list_messages(
|
||||
"receiver_node_id": m.receiver_node_id,
|
||||
"received_by": receiver_pk,
|
||||
"receiver_name": receiver_name,
|
||||
"receiver_friendly_name": _get_friendly_name(receiver_node),
|
||||
"receiver_tag_name": _get_tag_name(receiver_node),
|
||||
"message_type": m.message_type,
|
||||
"pubkey_prefix": m.pubkey_prefix,
|
||||
"sender_name": (
|
||||
sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"sender_friendly_name": (
|
||||
friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
"sender_tag_name": (
|
||||
sender_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
|
||||
),
|
||||
"channel_idx": m.channel_idx,
|
||||
"text": m.text,
|
||||
|
||||
@@ -6,7 +6,13 @@ from sqlalchemy import select
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
|
||||
from meshcore_hub.common.schemas.nodes import (
|
||||
NodeTagCreate,
|
||||
NodeTagMove,
|
||||
NodeTagRead,
|
||||
NodeTagsCopyResult,
|
||||
NodeTagUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -130,6 +136,131 @@ async def update_node_tag(
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.put("/nodes/{public_key}/tags/{key}/move", response_model=NodeTagRead)
|
||||
async def move_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
data: NodeTagMove,
|
||||
) -> NodeTagRead:
|
||||
"""Move a node tag to a different node."""
|
||||
# Check if source and destination are the same
|
||||
if public_key == data.new_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == source_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == data.new_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Check if tag already exists on destination node
|
||||
conflict_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == dest_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
conflict = session.execute(conflict_query).scalar_one_or_none()
|
||||
|
||||
if conflict:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Tag '{key}' already exists on destination node",
|
||||
)
|
||||
|
||||
# Move tag to destination node
|
||||
node_tag.node_id = dest_node.id
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
response_model=NodeTagsCopyResult,
|
||||
)
|
||||
async def copy_all_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
dest_public_key: str,
|
||||
) -> NodeTagsCopyResult:
|
||||
"""Copy all tags from one node to another.
|
||||
|
||||
Tags that already exist on the destination node are skipped.
|
||||
"""
|
||||
# Check if source and destination are the same
|
||||
if public_key == dest_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == dest_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Get existing tags on destination node
|
||||
existing_query = select(NodeTag.key).where(NodeTag.node_id == dest_node.id)
|
||||
existing_keys = set(session.execute(existing_query).scalars().all())
|
||||
|
||||
# Copy tags
|
||||
copied = 0
|
||||
skipped_keys = []
|
||||
|
||||
for tag in source_node.tags:
|
||||
if tag.key in existing_keys:
|
||||
skipped_keys.append(tag.key)
|
||||
continue
|
||||
|
||||
new_tag = NodeTag(
|
||||
node_id=dest_node.id,
|
||||
key=tag.key,
|
||||
value=tag.value,
|
||||
value_type=tag.value_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
copied += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
return NodeTagsCopyResult(
|
||||
copied=copied,
|
||||
skipped=len(skipped_keys),
|
||||
skipped_keys=skipped_keys,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
|
||||
async def delete_node_tag(
|
||||
_: RequireAdmin,
|
||||
@@ -156,3 +287,27 @@ async def delete_node_tag(
|
||||
|
||||
session.delete(node_tag)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags")
|
||||
async def delete_all_node_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> dict:
|
||||
"""Delete all tags for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Count and delete all tags
|
||||
count = len(node.tags)
|
||||
for tag in node.tags:
|
||||
session.delete(tag)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"deleted": count}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
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,28 +18,63 @@ 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"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
role: Optional[str] = Query(None, description="Filter by role tag value"),
|
||||
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:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
if member_id:
|
||||
# Filter nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# Filter nodes that have a role tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "role", NodeTag.value == role
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
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
|
||||
@@ -52,14 +88,43 @@ async def list_nodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
@router.get("/prefix/{prefix}", response_model=NodeRead)
|
||||
async def get_node_by_prefix(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
prefix: str = Path(description="Public key prefix to search for"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by public key."""
|
||||
query = select(Node).where(Node.public_key == public_key)
|
||||
"""Get a single node by public key prefix.
|
||||
|
||||
Returns the first node (alphabetically by public_key) that matches the prefix.
|
||||
"""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.order_by(Node.public_key)
|
||||
.limit(1)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return NodeRead.model_validate(node)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str = Path(description="Full 64-character public key"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by exact public key match."""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key == public_key)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
|
||||
@@ -170,8 +170,8 @@ def _run_collector_service(
|
||||
) -> None:
|
||||
"""Run the collector service.
|
||||
|
||||
On startup, automatically seeds the database from YAML files in seed_home
|
||||
if they exist.
|
||||
Note: Seed data import should be done using the 'meshcore-hub collector seed'
|
||||
command or the dedicated seed container before starting the collector service.
|
||||
|
||||
Webhooks can be configured via environment variables:
|
||||
- WEBHOOK_ADVERTISEMENT_URL: Webhook for advertisement events
|
||||
@@ -193,31 +193,6 @@ def _run_collector_service(
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Database: {database_url}")
|
||||
|
||||
# Initialize database (schema managed by Alembic migrations)
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
db = DatabaseManager(database_url)
|
||||
|
||||
# Auto-seed from seed files on startup
|
||||
click.echo("")
|
||||
click.echo("Checking for seed files...")
|
||||
seed_home_path = Path(seed_home)
|
||||
node_tags_exists = (seed_home_path / "node_tags.yaml").exists()
|
||||
members_exists = (seed_home_path / "members.yaml").exists()
|
||||
|
||||
if node_tags_exists or members_exists:
|
||||
click.echo("Running seed import...")
|
||||
_run_seed_import(
|
||||
seed_home=seed_home,
|
||||
db=db,
|
||||
create_nodes=True,
|
||||
verbose=True,
|
||||
)
|
||||
else:
|
||||
click.echo(f"No seed files found in {seed_home}")
|
||||
|
||||
db.dispose()
|
||||
|
||||
# Load webhook configuration from settings
|
||||
from meshcore_hub.collector.webhook import (
|
||||
WebhookDispatcher,
|
||||
@@ -383,8 +358,11 @@ def _run_seed_import(
|
||||
file_path=str(node_tags_file),
|
||||
db=db,
|
||||
create_nodes=create_nodes,
|
||||
clear_existing=True,
|
||||
)
|
||||
if verbose:
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Deleted {stats['deleted']} existing tags")
|
||||
click.echo(
|
||||
f" Tags: {stats['created']} created, {stats['updated']} updated"
|
||||
)
|
||||
@@ -428,16 +406,24 @@ def _run_seed_import(
|
||||
default=False,
|
||||
help="Skip tags for nodes that don't exist (default: create nodes)",
|
||||
)
|
||||
@click.option(
|
||||
"--clear-existing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Delete all existing tags before importing",
|
||||
)
|
||||
@click.pass_context
|
||||
def import_tags_cmd(
|
||||
ctx: click.Context,
|
||||
file: str | None,
|
||||
no_create_nodes: bool,
|
||||
clear_existing: bool,
|
||||
) -> None:
|
||||
"""Import node tags from a YAML file.
|
||||
|
||||
Reads a YAML file containing tag definitions and upserts them
|
||||
into the database. Existing tags are updated, new tags are created.
|
||||
into the database. By default, existing tags are updated and new tags are created.
|
||||
Use --clear-existing to delete all tags before importing.
|
||||
|
||||
FILE is the path to the YAML file containing tags.
|
||||
If not provided, defaults to {SEED_HOME}/node_tags.yaml.
|
||||
@@ -447,12 +433,12 @@ def import_tags_cmd(
|
||||
\b
|
||||
0123456789abcdef...:
|
||||
friendly_name: My Node
|
||||
location:
|
||||
value: "52.0,1.0"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
active:
|
||||
value: "true"
|
||||
type: boolean
|
||||
|
||||
Shorthand is also supported (string values with default type):
|
||||
|
||||
@@ -461,7 +447,7 @@ def import_tags_cmd(
|
||||
friendly_name: My Node
|
||||
role: gateway
|
||||
|
||||
Supported types: string, number, boolean, coordinate
|
||||
Supported types: string, number, boolean
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -492,11 +478,14 @@ def import_tags_cmd(
|
||||
file_path=tags_file,
|
||||
db=db,
|
||||
create_nodes=not no_create_nodes,
|
||||
clear_existing=clear_existing,
|
||||
)
|
||||
|
||||
# Report results
|
||||
click.echo("")
|
||||
click.echo("Import complete:")
|
||||
if stats["deleted"]:
|
||||
click.echo(f" Tags deleted: {stats['deleted']}")
|
||||
click.echo(f" Total tags in file: {stats['total']}")
|
||||
click.echo(f" Tags created: {stats['created']}")
|
||||
click.echo(f" Tags updated: {stats['updated']}")
|
||||
@@ -674,3 +663,212 @@ def cleanup_cmd(
|
||||
db.dispose()
|
||||
click.echo("")
|
||||
click.echo("Cleanup complete." if not dry_run else "Dry run complete.")
|
||||
|
||||
|
||||
@collector.command("truncate")
|
||||
@click.option(
|
||||
"--members",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate members table",
|
||||
)
|
||||
@click.option(
|
||||
"--nodes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate nodes table (also clears tags, advertisements, messages, telemetry, trace paths)",
|
||||
)
|
||||
@click.option(
|
||||
"--messages",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate messages table",
|
||||
)
|
||||
@click.option(
|
||||
"--advertisements",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate advertisements table",
|
||||
)
|
||||
@click.option(
|
||||
"--telemetry",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate telemetry table",
|
||||
)
|
||||
@click.option(
|
||||
"--trace-paths",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate trace_paths table",
|
||||
)
|
||||
@click.option(
|
||||
"--event-logs",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate event_logs table",
|
||||
)
|
||||
@click.option(
|
||||
"--all",
|
||||
"truncate_all",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate ALL tables (use with caution!)",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
@click.pass_context
|
||||
def truncate_cmd(
|
||||
ctx: click.Context,
|
||||
members: bool,
|
||||
nodes: bool,
|
||||
messages: bool,
|
||||
advertisements: bool,
|
||||
telemetry: bool,
|
||||
trace_paths: bool,
|
||||
event_logs: bool,
|
||||
truncate_all: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""Truncate (clear) data tables.
|
||||
|
||||
WARNING: This permanently deletes data! Use with caution.
|
||||
|
||||
Examples:
|
||||
# Clear members table
|
||||
meshcore-hub collector truncate --members
|
||||
|
||||
# Clear messages and advertisements
|
||||
meshcore-hub collector truncate --messages --advertisements
|
||||
|
||||
# Clear everything (requires confirmation)
|
||||
meshcore-hub collector truncate --all
|
||||
|
||||
Note: Clearing nodes also clears all related data (tags, advertisements,
|
||||
messages, telemetry, trace paths) due to foreign key constraints.
|
||||
"""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
# Determine what to truncate
|
||||
if truncate_all:
|
||||
tables_to_clear = {
|
||||
"members": True,
|
||||
"nodes": True,
|
||||
"messages": True,
|
||||
"advertisements": True,
|
||||
"telemetry": True,
|
||||
"trace_paths": True,
|
||||
"event_logs": True,
|
||||
}
|
||||
else:
|
||||
tables_to_clear = {
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
"messages": messages,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"trace_paths": trace_paths,
|
||||
"event_logs": event_logs,
|
||||
}
|
||||
|
||||
# Check if any tables selected
|
||||
if not any(tables_to_clear.values()):
|
||||
click.echo("No tables specified. Use --help to see available options.")
|
||||
return
|
||||
|
||||
# Show what will be cleared
|
||||
click.echo("Database: " + ctx.obj["database_url"])
|
||||
click.echo("")
|
||||
click.echo("The following tables will be PERMANENTLY CLEARED:")
|
||||
for table, should_clear in tables_to_clear.items():
|
||||
if should_clear:
|
||||
click.echo(f" - {table}")
|
||||
|
||||
if tables_to_clear.get("nodes"):
|
||||
click.echo("")
|
||||
click.echo(
|
||||
"WARNING: Clearing nodes will also clear all related data due to foreign keys:"
|
||||
)
|
||||
click.echo(" - node_tags")
|
||||
click.echo(" - advertisements")
|
||||
click.echo(" - messages")
|
||||
click.echo(" - telemetry")
|
||||
click.echo(" - trace_paths")
|
||||
|
||||
click.echo("")
|
||||
|
||||
# Confirm
|
||||
if not yes:
|
||||
if not click.confirm(
|
||||
"Are you sure you want to permanently delete this data?", default=False
|
||||
):
|
||||
click.echo("Aborted.")
|
||||
return
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Truncate in correct order to respect foreign keys
|
||||
cleared: list[str] = []
|
||||
|
||||
# Clear members (no dependencies)
|
||||
if tables_to_clear.get("members"):
|
||||
result: CursorResult = session.execute(delete(Member)) # type: ignore
|
||||
cleared.append(f"members: {result.rowcount} rows")
|
||||
|
||||
# Clear event-specific tables first (they depend on nodes)
|
||||
if tables_to_clear.get("messages"):
|
||||
result = session.execute(delete(Message)) # type: ignore
|
||||
cleared.append(f"messages: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("advertisements"):
|
||||
result = session.execute(delete(Advertisement)) # type: ignore
|
||||
cleared.append(f"advertisements: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("telemetry"):
|
||||
result = session.execute(delete(Telemetry)) # type: ignore
|
||||
cleared.append(f"telemetry: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("trace_paths"):
|
||||
result = session.execute(delete(TracePath)) # type: ignore
|
||||
cleared.append(f"trace_paths: {result.rowcount} rows")
|
||||
|
||||
if tables_to_clear.get("event_logs"):
|
||||
result = session.execute(delete(EventLog)) # type: ignore
|
||||
cleared.append(f"event_logs: {result.rowcount} rows")
|
||||
|
||||
# Clear nodes last (this will cascade delete tags and any remaining events)
|
||||
if tables_to_clear.get("nodes"):
|
||||
# Delete tags first (they depend on nodes)
|
||||
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
|
||||
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
|
||||
|
||||
# Delete nodes (will cascade to remaining related tables)
|
||||
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
|
||||
cleared.append(f"nodes: {node_result.rowcount} rows")
|
||||
|
||||
db.dispose()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Truncate complete. Cleared:")
|
||||
for item in cleared:
|
||||
click.echo(f" - {item}")
|
||||
click.echo("")
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_contact(
|
||||
# Device uses 'adv_name' for the advertised name
|
||||
name = payload.get("adv_name") or payload.get("name")
|
||||
|
||||
# GPS coordinates (optional)
|
||||
lat = payload.get("adv_lat")
|
||||
lon = payload.get("adv_lon")
|
||||
|
||||
logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}")
|
||||
|
||||
# Device uses numeric 'type' field, convert to string
|
||||
@@ -73,15 +77,24 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
node.last_seen = now
|
||||
# Update GPS coordinates if provided
|
||||
if lat is not None:
|
||||
node.lat = lat
|
||||
if lon is not None:
|
||||
node.lon = lon
|
||||
# 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
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -5,41 +5,28 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Member, MemberNode
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NodeData(BaseModel):
|
||||
"""Schema for a node entry in the member import file."""
|
||||
|
||||
public_key: str = Field(..., min_length=64, max_length=64)
|
||||
node_role: Optional[str] = Field(default=None, max_length=50)
|
||||
|
||||
@field_validator("public_key")
|
||||
@classmethod
|
||||
def validate_public_key(cls, v: str) -> str:
|
||||
"""Validate and normalize public key."""
|
||||
if len(v) != 64:
|
||||
raise ValueError(f"public_key must be 64 characters, got {len(v)}")
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in v):
|
||||
raise ValueError("public_key must be a valid hex string")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class MemberData(BaseModel):
|
||||
"""Schema for a member entry in the import file."""
|
||||
"""Schema for a member entry in the import file.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
callsign: Optional[str] = Field(default=None, max_length=20)
|
||||
role: Optional[str] = Field(default=None, max_length=100)
|
||||
description: Optional[str] = Field(default=None)
|
||||
contact: Optional[str] = Field(default=None, max_length=255)
|
||||
nodes: Optional[list[NodeData]] = Field(default=None)
|
||||
|
||||
|
||||
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
@@ -48,20 +35,16 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
Supports two formats:
|
||||
1. List of member objects:
|
||||
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
2. Object with "members" key:
|
||||
|
||||
members:
|
||||
- name: Member 1
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
nodes:
|
||||
- public_key: abc123...
|
||||
node_role: chat
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -96,6 +79,8 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
for i, member in enumerate(members_list):
|
||||
if not isinstance(member, dict):
|
||||
raise ValueError(f"Member at index {i} must be an object")
|
||||
if "member_id" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'member_id' field")
|
||||
if "name" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'name' field")
|
||||
|
||||
@@ -115,9 +100,11 @@ def import_members(
|
||||
) -> dict[str, Any]:
|
||||
"""Import members from a YAML file into the database.
|
||||
|
||||
Performs upsert operations based on name - existing members are updated,
|
||||
new members are created. Nodes are synced (existing nodes removed and
|
||||
replaced with new ones from the file).
|
||||
Performs upsert operations based on member_id - existing members are updated,
|
||||
new members are created.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
This import does not manage node associations.
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
@@ -149,14 +136,17 @@ def import_members(
|
||||
with db.session_scope() as session:
|
||||
for member_data in members_data:
|
||||
try:
|
||||
member_id = member_data["member_id"]
|
||||
name = member_data["name"]
|
||||
|
||||
# Find existing member by name
|
||||
query = select(Member).where(Member.name == name)
|
||||
# Find existing member by member_id
|
||||
query = select(Member).where(Member.member_id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update existing member
|
||||
if member_data.get("name") is not None:
|
||||
existing.name = member_data["name"]
|
||||
if member_data.get("callsign") is not None:
|
||||
existing.callsign = member_data["callsign"]
|
||||
if member_data.get("role") is not None:
|
||||
@@ -166,25 +156,12 @@ def import_members(
|
||||
if member_data.get("contact") is not None:
|
||||
existing.contact = member_data["contact"]
|
||||
|
||||
# Sync nodes if provided
|
||||
if member_data.get("nodes") is not None:
|
||||
# Remove existing nodes
|
||||
existing.nodes.clear()
|
||||
|
||||
# Add new nodes
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=existing.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
existing.nodes.append(node)
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"Updated member: {name}")
|
||||
logger.debug(f"Updated member: {member_id} ({name})")
|
||||
else:
|
||||
# Create new member
|
||||
new_member = Member(
|
||||
member_id=member_id,
|
||||
name=name,
|
||||
callsign=member_data.get("callsign"),
|
||||
role=member_data.get("role"),
|
||||
@@ -192,23 +169,12 @@ def import_members(
|
||||
contact=member_data.get("contact"),
|
||||
)
|
||||
session.add(new_member)
|
||||
session.flush() # Get the ID for the member
|
||||
|
||||
# Add nodes if provided
|
||||
if member_data.get("nodes"):
|
||||
for node_data in member_data["nodes"]:
|
||||
node = MemberNode(
|
||||
member_id=new_member.id,
|
||||
public_key=node_data["public_key"],
|
||||
node_role=node_data.get("node_role"),
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.debug(f"Created member: {name}")
|
||||
logger.debug(f"Created member: {member_id} ({name})")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing member '{member_data.get('name', 'unknown')}': {e}"
|
||||
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
|
||||
stats["errors"].append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
@@ -19,7 +19,7 @@ class TagValue(BaseModel):
|
||||
"""Schema for a tag value with type."""
|
||||
|
||||
value: str | None = None
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean|coordinate)$")
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean)$")
|
||||
|
||||
|
||||
class NodeTags(BaseModel):
|
||||
@@ -151,16 +151,19 @@ def import_tags(
|
||||
file_path: str | Path,
|
||||
db: DatabaseManager,
|
||||
create_nodes: bool = True,
|
||||
clear_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import tags from a YAML file into the database.
|
||||
|
||||
Performs upsert operations - existing tags are updated, new tags are created.
|
||||
Optionally clears all existing tags before import.
|
||||
|
||||
Args:
|
||||
file_path: Path to the tags YAML file
|
||||
db: Database manager instance
|
||||
create_nodes: If True, create nodes that don't exist. If False, skip tags
|
||||
for non-existent nodes.
|
||||
clear_existing: If True, delete all existing tags before importing.
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics:
|
||||
@@ -169,6 +172,7 @@ def import_tags(
|
||||
- updated: Number of existing tags updated
|
||||
- skipped: Number of tags skipped (node not found and create_nodes=False)
|
||||
- nodes_created: Number of new nodes created
|
||||
- deleted: Number of existing tags deleted (if clear_existing=True)
|
||||
- errors: List of error messages
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
@@ -177,6 +181,7 @@ def import_tags(
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"nodes_created": 0,
|
||||
"deleted": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@@ -194,6 +199,15 @@ def import_tags(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Clear all existing tags if requested
|
||||
if clear_existing:
|
||||
delete_count = (
|
||||
session.execute(select(func.count()).select_from(NodeTag)).scalar() or 0
|
||||
)
|
||||
session.execute(delete(NodeTag))
|
||||
stats["deleted"] = delete_count
|
||||
logger.info(f"Deleted {delete_count} existing tags")
|
||||
|
||||
# Cache nodes by public_key to reduce queries
|
||||
node_cache: dict[str, Node] = {}
|
||||
|
||||
@@ -232,24 +246,8 @@ def import_tags(
|
||||
tag_value = tag_data.get("value")
|
||||
tag_type = tag_data.get("type", "string")
|
||||
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
if clear_existing:
|
||||
# When clearing, always create new tags
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
@@ -262,6 +260,39 @@ def import_tags(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Find or create tag
|
||||
tag_query = select(NodeTag).where(
|
||||
NodeTag.node_id == node.id,
|
||||
NodeTag.key == tag_key,
|
||||
)
|
||||
existing_tag = session.execute(
|
||||
tag_query
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Update existing tag
|
||||
existing_tag.value = tag_value
|
||||
existing_tag.value_type = tag_type
|
||||
stats["updated"] += 1
|
||||
logger.debug(
|
||||
f"Updated tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
else:
|
||||
# Create new tag
|
||||
new_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag_key,
|
||||
value=tag_value,
|
||||
value_type=tag_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
stats["created"] += 1
|
||||
logger.debug(
|
||||
f"Created tag {tag_key}={tag_value} "
|
||||
f"for {public_key[:12]}..."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing tag {tag_key} for {public_key[:12]}...: {e}"
|
||||
|
||||
@@ -78,6 +78,17 @@ class InterfaceSettings(CommonSettings):
|
||||
default=None, description="Device/node name (optional)"
|
||||
)
|
||||
|
||||
# Contact cleanup settings
|
||||
contact_cleanup_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable automatic removal of stale contacts from companion node",
|
||||
)
|
||||
contact_cleanup_days: int = Field(
|
||||
default=7,
|
||||
description="Remove contacts not advertised for this many days",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class CollectorSettings(CommonSettings):
|
||||
"""Settings for the Collector component."""
|
||||
@@ -242,6 +253,12 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
|
||||
)
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
@@ -278,6 +295,19 @@ class WebSettings(CommonSettings):
|
||||
default=None, description="Welcome text for homepage"
|
||||
)
|
||||
|
||||
# Custom pages directory
|
||||
pages_home: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Directory containing custom markdown pages (default: ./pages)",
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_pages_home(self) -> str:
|
||||
"""Get the effective pages home directory."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.pages_home or "./pages"))
|
||||
|
||||
@property
|
||||
def web_data_dir(self) -> str:
|
||||
"""Get the web data directory path."""
|
||||
|
||||
@@ -98,6 +98,15 @@ class DatabaseManager:
|
||||
echo: Enable SQL query logging
|
||||
"""
|
||||
self.database_url = database_url
|
||||
|
||||
# Ensure parent directory exists for SQLite databases
|
||||
if database_url.startswith("sqlite:///"):
|
||||
from pathlib import Path
|
||||
|
||||
# Extract path from sqlite:///path/to/db.sqlite
|
||||
db_path = Path(database_url.replace("sqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.engine = create_database_engine(database_url, echo=echo)
|
||||
self.session_factory = create_session_factory(self.engine)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def compute_advertisement_hash(
|
||||
adv_type: Optional[str] = None,
|
||||
flags: Optional[int] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for an advertisement.
|
||||
|
||||
@@ -104,7 +104,7 @@ def compute_telemetry_hash(
|
||||
node_public_key: str,
|
||||
parsed_data: Optional[dict] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for a telemetry record.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from meshcore_hub.common.models.trace_path import TracePath
|
||||
from meshcore_hub.common.models.telemetry import Telemetry
|
||||
from meshcore_hub.common.models.event_log import EventLog
|
||||
from meshcore_hub.common.models.member import Member
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
from meshcore_hub.common.models.event_receiver import EventReceiver, add_event_receiver
|
||||
|
||||
__all__ = [
|
||||
@@ -23,7 +22,6 @@ __all__ = [
|
||||
"Telemetry",
|
||||
"EventLog",
|
||||
"Member",
|
||||
"MemberNode",
|
||||
"EventReceiver",
|
||||
"add_event_receiver",
|
||||
]
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
"""Member model for network member information."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member_node import MemberNode
|
||||
|
||||
|
||||
class Member(Base, UUIDMixin, TimestampMixin):
|
||||
"""Member model for network member information.
|
||||
|
||||
Stores information about network members/operators.
|
||||
Members can have multiple associated nodes (chat, repeater, etc.).
|
||||
Nodes are associated with members via a 'member_id' tag on the node.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Unique member identifier (e.g., 'walshie86')
|
||||
name: Member's display name
|
||||
callsign: Amateur radio callsign (optional)
|
||||
role: Member's role in the network (optional)
|
||||
description: Additional description (optional)
|
||||
contact: Contact information (optional)
|
||||
nodes: List of associated MemberNode records
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "members"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
@@ -52,11 +55,5 @@ class Member(Base, UUIDMixin, TimestampMixin):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship to member nodes
|
||||
nodes: Mapped[list["MemberNode"]] = relationship(
|
||||
back_populates="member",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, name={self.name}, callsign={self.callsign})>"
|
||||
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""MemberNode model for associating nodes with members."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.member import Member
|
||||
|
||||
|
||||
class MemberNode(Base, UUIDMixin, TimestampMixin):
|
||||
"""Association model linking members to their nodes.
|
||||
|
||||
A member can have multiple nodes (e.g., chat node, repeater).
|
||||
Each node is identified by its public_key and has a role.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Foreign key to the member
|
||||
public_key: Node's public key (64-char hex)
|
||||
node_role: Role of the node (e.g., 'chat', 'repeater')
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "member_nodes"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("members.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_role: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationship back to member
|
||||
member: Mapped["Member"] = relationship(back_populates="nodes")
|
||||
|
||||
# Composite index for efficient lookups
|
||||
__table_args__ = (
|
||||
Index("ix_member_nodes_member_public_key", "member_id", "public_key"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MemberNode(member_id={self.member_id}, public_key={self.public_key[:8]}..., role={self.node_role})>"
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
@@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
lat: GPS latitude coordinate (if available)
|
||||
lon: GPS longitude coordinate (if available)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -52,10 +54,18 @@ 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,
|
||||
)
|
||||
lat: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
lon: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -21,7 +21,7 @@ class NodeTag(Base, UUIDMixin, TimestampMixin):
|
||||
node_id: Foreign key to nodes table
|
||||
key: Tag name/key
|
||||
value: Tag value (stored as text, can be JSON for typed values)
|
||||
value_type: Type hint (string, number, boolean, coordinate)
|
||||
value_type: Type hint (string, number, boolean)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
@@ -6,46 +6,19 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemberNodeCreate(BaseModel):
|
||||
"""Schema for creating a member node association."""
|
||||
|
||||
public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description="Node's public key (64-char hex)",
|
||||
)
|
||||
node_role: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=50,
|
||||
description="Role of the node (e.g., 'chat', 'repeater')",
|
||||
)
|
||||
|
||||
|
||||
class MemberNodeRead(BaseModel):
|
||||
"""Schema for reading a member node association."""
|
||||
|
||||
public_key: str = Field(..., description="Node's public key")
|
||||
node_role: Optional[str] = Field(default=None, description="Role of the node")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
# Node details (populated from nodes table if available)
|
||||
node_name: Optional[str] = Field(default=None, description="Node's name from DB")
|
||||
node_adv_type: Optional[str] = Field(
|
||||
default=None, description="Node's advertisement type"
|
||||
)
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node's friendly name tag"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Schema for creating a member."""
|
||||
"""Schema for creating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
@@ -71,15 +44,21 @@ class MemberCreate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes",
|
||||
)
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Schema for updating a member."""
|
||||
"""Schema for updating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
@@ -105,22 +84,22 @@ class MemberUpdate(BaseModel):
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||
default=None,
|
||||
description="List of associated nodes (replaces existing nodes)",
|
||||
)
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
"""Schema for reading a member."""
|
||||
"""Schema for reading a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Member UUID")
|
||||
member_id: str = Field(..., description="Unique member identifier")
|
||||
name: str = Field(..., description="Member's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
role: Optional[str] = Field(default=None, description="Member's role")
|
||||
description: Optional[str] = Field(default=None, description="Description")
|
||||
contact: Optional[str] = Field(default=None, description="Contact information")
|
||||
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ class ReceiverInfo(BaseModel):
|
||||
node_id: str = Field(..., description="Receiver node UUID")
|
||||
public_key: str = Field(..., description="Receiver node public key")
|
||||
name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
)
|
||||
tag_name: Optional[str] = Field(default=None, description="Receiver name from tags")
|
||||
snr: Optional[float] = Field(
|
||||
default=None, description="Signal-to-noise ratio at this receiver"
|
||||
)
|
||||
@@ -31,8 +29,8 @@ class MessageRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
message_type: str = Field(..., description="Message type (contact, channel)")
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
@@ -41,8 +39,8 @@ class MessageRead(BaseModel):
|
||||
sender_name: Optional[str] = Field(
|
||||
default=None, description="Sender's advertised node name"
|
||||
)
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender's friendly name from node tags"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender's name from node tags"
|
||||
)
|
||||
channel_idx: Optional[int] = Field(default=None, description="Channel index")
|
||||
text: str = Field(..., description="Message content")
|
||||
@@ -110,16 +108,16 @@ class AdvertisementRead(BaseModel):
|
||||
default=None, description="Receiving interface node public key"
|
||||
)
|
||||
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
|
||||
receiver_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Receiver friendly name from tags"
|
||||
receiver_tag_name: Optional[str] = Field(
|
||||
default=None, description="Receiver name from tags"
|
||||
)
|
||||
public_key: str = Field(..., description="Advertised public key")
|
||||
name: Optional[str] = Field(default=None, description="Advertised name")
|
||||
node_name: Optional[str] = Field(
|
||||
default=None, description="Node name from nodes table"
|
||||
)
|
||||
node_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Node friendly name from tags"
|
||||
node_tag_name: Optional[str] = Field(
|
||||
default=None, description="Node name from tags"
|
||||
)
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
@@ -215,7 +213,7 @@ class RecentAdvertisement(BaseModel):
|
||||
|
||||
public_key: str = Field(..., description="Node public key")
|
||||
name: Optional[str] = Field(default=None, description="Node name")
|
||||
friendly_name: Optional[str] = Field(default=None, description="Friendly name tag")
|
||||
tag_name: Optional[str] = Field(default=None, description="Name tag")
|
||||
adv_type: Optional[str] = Field(default=None, description="Node type")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
|
||||
@@ -225,8 +223,8 @@ class ChannelMessage(BaseModel):
|
||||
|
||||
text: str = Field(..., description="Message text")
|
||||
sender_name: Optional[str] = Field(default=None, description="Sender name")
|
||||
sender_friendly_name: Optional[str] = Field(
|
||||
default=None, description="Sender friendly name"
|
||||
sender_tag_name: Optional[str] = Field(
|
||||
default=None, description="Sender name from tags"
|
||||
)
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
default=None, description="Sender public key prefix"
|
||||
@@ -241,10 +239,14 @@ class DashboardStats(BaseModel):
|
||||
active_nodes: int = Field(..., description="Nodes active in last 24h")
|
||||
total_messages: int = Field(..., description="Total number of messages")
|
||||
messages_today: int = Field(..., description="Messages received today")
|
||||
messages_7d: int = Field(default=0, description="Messages received in last 7 days")
|
||||
total_advertisements: int = Field(..., description="Total advertisements")
|
||||
advertisements_24h: int = Field(
|
||||
default=0, description="Advertisements received in last 24h"
|
||||
)
|
||||
advertisements_7d: int = Field(
|
||||
default=0, description="Advertisements received in last 7 days"
|
||||
)
|
||||
recent_advertisements: list[RecentAdvertisement] = Field(
|
||||
default_factory=list, description="Last 10 advertisements"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ class NodeTagCreate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Literal["string", "number", "boolean", "coordinate"] = Field(
|
||||
value_type: Literal["string", "number", "boolean"] = Field(
|
||||
default="string",
|
||||
description="Value type hint",
|
||||
)
|
||||
@@ -32,12 +32,33 @@ class NodeTagUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field(
|
||||
value_type: Optional[Literal["string", "number", "boolean"]] = Field(
|
||||
default=None,
|
||||
description="Value type hint",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagMove(BaseModel):
|
||||
"""Schema for moving a node tag to a different node."""
|
||||
|
||||
new_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Public key of the destination node",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagsCopyResult(BaseModel):
|
||||
"""Schema for bulk copy tags result."""
|
||||
|
||||
copied: int = Field(..., description="Number of tags copied")
|
||||
skipped: int = Field(..., description="Number of tags skipped (already exist)")
|
||||
skipped_keys: list[str] = Field(
|
||||
default_factory=list, description="Keys of skipped tags"
|
||||
)
|
||||
|
||||
|
||||
class NodeTagRead(BaseModel):
|
||||
"""Schema for reading a node tag."""
|
||||
|
||||
@@ -59,7 +80,11 @@ 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"
|
||||
)
|
||||
lat: Optional[float] = Field(default=None, description="GPS latitude coordinate")
|
||||
lon: Optional[float] = Field(default=None, description="GPS longitude coordinate")
|
||||
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 +107,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,
|
||||
|
||||
@@ -100,6 +100,19 @@ def interface() -> None:
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
@@ -120,6 +133,8 @@ def run(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the interface component.
|
||||
@@ -162,6 +177,8 @@ def run(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
elif mode_upper == "SENDER":
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
@@ -262,6 +279,19 @@ def run(
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days",
|
||||
)
|
||||
def receiver(
|
||||
port: str,
|
||||
baud: int,
|
||||
@@ -274,6 +304,8 @@ def receiver(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
) -> None:
|
||||
"""Run interface in RECEIVER mode.
|
||||
|
||||
@@ -293,12 +325,15 @@ def receiver(
|
||||
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,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -193,11 +193,50 @@ 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 remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's contact database.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if contact was removed successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if request was scheduled successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
@@ -567,7 +606,12 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
return False
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from device contact database."""
|
||||
"""Fetch contacts from device contact database.
|
||||
|
||||
Note: This method should only be called before the event loop is running
|
||||
(e.g., during initialization). For calling during event processing,
|
||||
use schedule_get_contacts() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -584,6 +628,79 @@ 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 remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's 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_remove_contact() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
self._loop.run_until_complete(_remove_contact())
|
||||
logger.info(f"Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove contact: {e}")
|
||||
return False
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact 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 remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_remove_contact(), self._loop)
|
||||
logger.debug(f"Scheduled removal of contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule remove contact: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -292,7 +292,10 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
return True
|
||||
|
||||
def get_contacts(self) -> bool:
|
||||
"""Fetch contacts from mock device contact database."""
|
||||
"""Fetch contacts from mock device contact database.
|
||||
|
||||
Note: This should only be called before the event loop is running.
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.error("Cannot get contacts: not connected")
|
||||
return False
|
||||
@@ -318,6 +321,38 @@ 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 remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the mock device's contact database."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
# Find and remove the contact from mock_config.nodes
|
||||
for i, node in enumerate(self.mock_config.nodes):
|
||||
if node.public_key == public_key:
|
||||
del self.mock_config.nodes[i]
|
||||
logger.info(f"Mock: Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
|
||||
logger.warning(f"Mock: Contact {public_key[:12]}... not found")
|
||||
return True # Return True even if not found (idempotent)
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request.
|
||||
|
||||
For the mock device, this is the same as remove_contact() since we
|
||||
don't have a real async event loop.
|
||||
"""
|
||||
return self.remove_contact(public_key)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -20,6 +20,9 @@ from meshcore_hub.interface.device import (
|
||||
create_device,
|
||||
)
|
||||
|
||||
# Default contact cleanup settings
|
||||
DEFAULT_CONTACT_CLEANUP_DAYS = 7
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +37,8 @@ class Receiver:
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
device_name: Optional[str] = None,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
):
|
||||
"""Initialize receiver.
|
||||
|
||||
@@ -41,10 +46,14 @@ class Receiver:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
device_name: Optional device/node name to set on startup
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self.device_name = device_name
|
||||
self.contact_cleanup_enabled = contact_cleanup_enabled
|
||||
self.contact_cleanup_days = contact_cleanup_days
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
self._device_connected = False
|
||||
@@ -144,14 +153,31 @@ 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.
|
||||
|
||||
The device returns contacts as a dict keyed by public_key.
|
||||
We split this into individual 'contact' events for cleaner processing.
|
||||
Stale contacts (not advertised for > contact_cleanup_days) are removed
|
||||
from the device and not published.
|
||||
|
||||
Args:
|
||||
payload: Dict of contacts keyed by public_key
|
||||
@@ -173,22 +199,54 @@ class Receiver:
|
||||
return
|
||||
|
||||
device_key = self.device.public_key # Capture for type narrowing
|
||||
count = 0
|
||||
current_time = int(time.time())
|
||||
stale_threshold = current_time - (self.contact_cleanup_days * 24 * 60 * 60)
|
||||
|
||||
published_count = 0
|
||||
removed_count = 0
|
||||
|
||||
for contact in contacts:
|
||||
if not isinstance(contact, dict):
|
||||
continue
|
||||
|
||||
public_key = contact.get("public_key")
|
||||
if not public_key:
|
||||
continue
|
||||
|
||||
# Check if contact is stale based on last_advert timestamp
|
||||
# Only check if cleanup is enabled and last_advert exists
|
||||
if self.contact_cleanup_enabled:
|
||||
last_advert = contact.get("last_advert")
|
||||
if last_advert is not None and last_advert > 0:
|
||||
if last_advert < stale_threshold:
|
||||
# Contact is stale - remove from device
|
||||
adv_name = contact.get("adv_name", contact.get("name", ""))
|
||||
logger.info(
|
||||
f"Removing stale contact {public_key[:12]}... "
|
||||
f"({adv_name}) - last advertised "
|
||||
f"{(current_time - last_advert) // 86400} days ago"
|
||||
)
|
||||
self.device.schedule_remove_contact(public_key)
|
||||
removed_count += 1
|
||||
continue # Don't publish stale contacts
|
||||
|
||||
try:
|
||||
self.mqtt.publish_event(
|
||||
device_key,
|
||||
"contact", # Use singular 'contact' for individual events
|
||||
contact,
|
||||
)
|
||||
count += 1
|
||||
published_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish contact event: {e}")
|
||||
|
||||
logger.info(f"Published {count} contact events to MQTT")
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
f"Contact sync: published {published_count}, "
|
||||
f"removed {removed_count} stale contacts"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Published {published_count} contact events to MQTT")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the receiver."""
|
||||
@@ -291,6 +349,8 @@ def create_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> Receiver:
|
||||
"""Create a configured receiver instance.
|
||||
|
||||
@@ -306,6 +366,8 @@ def create_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
|
||||
Returns:
|
||||
Configured Receiver instance
|
||||
@@ -330,7 +392,13 @@ def create_receiver(
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Receiver(device, mqtt_client, device_name=device_name)
|
||||
return Receiver(
|
||||
device,
|
||||
mqtt_client,
|
||||
device_name=device_name,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
def run_receiver(
|
||||
@@ -345,6 +413,8 @@ def run_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> None:
|
||||
"""Run the receiver (blocking).
|
||||
|
||||
@@ -362,6 +432,8 @@ def run_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
receiver = create_receiver(
|
||||
port=port,
|
||||
@@ -375,6 +447,8 @@ def run_receiver(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -7,11 +7,14 @@ from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.schemas import RadioConfig
|
||||
from meshcore_hub.web.pages import PageLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +53,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
admin_enabled: bool | None = None,
|
||||
network_name: str | None = None,
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
@@ -67,6 +71,7 @@ def create_app(
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
admin_enabled: Enable admin interface at /a/
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
@@ -96,6 +101,9 @@ def create_app(
|
||||
# Store configuration in app state (use args if provided, else settings)
|
||||
app.state.api_url = api_url or settings.api_base_url
|
||||
app.state.api_key = api_key or settings.api_key
|
||||
app.state.admin_enabled = (
|
||||
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
|
||||
)
|
||||
app.state.network_name = network_name or settings.network_name
|
||||
app.state.network_city = network_city or settings.network_city
|
||||
app.state.network_country = network_country or settings.network_country
|
||||
@@ -119,6 +127,11 @@ def create_app(
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
app.state.templates = templates
|
||||
|
||||
# Initialize page loader for custom markdown pages
|
||||
page_loader = PageLoader(settings.effective_pages_home)
|
||||
page_loader.load_pages()
|
||||
app.state.page_loader = page_loader
|
||||
|
||||
# Mount static files
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
@@ -145,6 +158,109 @@ def create_app(
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "api": str(e)}
|
||||
|
||||
def _get_https_base_url(request: Request) -> str:
|
||||
"""Get base URL, ensuring HTTPS is used for public-facing URLs."""
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
# Ensure HTTPS for sitemaps and robots.txt (SEO requires canonical URLs)
|
||||
if base_url.startswith("http://"):
|
||||
base_url = "https://" + base_url[7:]
|
||||
return base_url
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots_txt(request: Request) -> str:
|
||||
"""Serve robots.txt to control search engine crawling."""
|
||||
base_url = _get_https_base_url(request)
|
||||
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml(request: Request) -> Response:
|
||||
"""Generate dynamic sitemap including all node pages."""
|
||||
base_url = _get_https_base_url(request)
|
||||
|
||||
# Static pages
|
||||
static_pages = [
|
||||
("", "daily", "1.0"),
|
||||
("/dashboard", "hourly", "0.9"),
|
||||
("/nodes", "hourly", "0.9"),
|
||||
("/advertisements", "hourly", "0.8"),
|
||||
("/messages", "hourly", "0.8"),
|
||||
("/map", "daily", "0.7"),
|
||||
("/members", "weekly", "0.6"),
|
||||
]
|
||||
|
||||
urls = []
|
||||
for path, changefreq, priority in static_pages:
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{path}</loc>\n"
|
||||
f" <changefreq>{changefreq}</changefreq>\n"
|
||||
f" <priority>{priority}</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
# Fetch infrastructure nodes for dynamic pages
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500, "role": "infra"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
nodes = response.json().get("items", [])
|
||||
for node in nodes:
|
||||
public_key = node.get("public_key")
|
||||
if public_key:
|
||||
# Use 8-char prefix (route handles redirect to full key)
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
|
||||
f" <changefreq>daily</changefreq>\n"
|
||||
f" <priority>0.5</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch nodes for sitemap: {response.status_code}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
|
||||
|
||||
# Add custom pages to sitemap
|
||||
page_loader = request.app.state.page_loader
|
||||
for page in page_loader.get_menu_pages():
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{page.url}</loc>\n"
|
||||
f" <changefreq>weekly</changefreq>\n"
|
||||
f" <priority>0.6</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>"
|
||||
)
|
||||
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(
|
||||
request: Request, exc: StarletteHTTPException
|
||||
) -> HTMLResponse:
|
||||
"""Handle HTTP exceptions with custom error pages."""
|
||||
if exc.status_code == 404:
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["detail"] = exc.detail if exc.detail != "Not Found" else None
|
||||
return templates.TemplateResponse(
|
||||
"errors/404.html", context, status_code=404
|
||||
)
|
||||
# For other errors, return a simple response
|
||||
return HTMLResponse(
|
||||
content=f"<h1>{exc.status_code}</h1><p>{exc.detail}</p>",
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -161,6 +277,10 @@ def get_network_context(request: Request) -> dict:
|
||||
request.app.state.network_radio_config
|
||||
)
|
||||
|
||||
# Get custom pages for navigation
|
||||
page_loader = request.app.state.page_loader
|
||||
custom_pages = page_loader.get_menu_pages()
|
||||
|
||||
return {
|
||||
"network_name": request.app.state.network_name,
|
||||
"network_city": request.app.state.network_city,
|
||||
@@ -170,5 +290,7 @@ def get_network_context(request: Request) -> dict:
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"admin_enabled": request.app.state.admin_enabled,
|
||||
"custom_pages": custom_pages,
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
119
src/meshcore_hub/web/pages.py
Normal file
119
src/meshcore_hub/web/pages.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomPage:
|
||||
"""Represents a custom markdown page."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
menu_order: int
|
||||
content_html: str
|
||||
file_path: str
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Get the URL path for this page."""
|
||||
return f"/pages/{self.slug}"
|
||||
|
||||
|
||||
class PageLoader:
|
||||
"""Loads and manages custom markdown pages from a directory."""
|
||||
|
||||
def __init__(self, pages_dir: str) -> None:
|
||||
"""Initialize the page loader.
|
||||
|
||||
Args:
|
||||
pages_dir: Path to the directory containing markdown pages.
|
||||
"""
|
||||
self.pages_dir = Path(pages_dir)
|
||||
self._pages: dict[str, CustomPage] = {}
|
||||
self._md = markdown.Markdown(
|
||||
extensions=["tables", "fenced_code", "toc"],
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
def load_pages(self) -> None:
|
||||
"""Load all markdown pages from the pages directory."""
|
||||
self._pages.clear()
|
||||
|
||||
if not self.pages_dir.exists():
|
||||
logger.debug(f"Pages directory does not exist: {self.pages_dir}")
|
||||
return
|
||||
|
||||
if not self.pages_dir.is_dir():
|
||||
logger.warning(f"Pages path is not a directory: {self.pages_dir}")
|
||||
return
|
||||
|
||||
for md_file in self.pages_dir.glob("*.md"):
|
||||
try:
|
||||
page = self._load_page(md_file)
|
||||
if page:
|
||||
self._pages[page.slug] = page
|
||||
logger.info(f"Loaded custom page: {page.slug} ({md_file.name})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load page {md_file}: {e}")
|
||||
|
||||
logger.info(f"Loaded {len(self._pages)} custom page(s)")
|
||||
|
||||
def _load_page(self, file_path: Path) -> Optional[CustomPage]:
|
||||
"""Load a single markdown page.
|
||||
|
||||
Args:
|
||||
file_path: Path to the markdown file.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if loading failed.
|
||||
"""
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
post = frontmatter.loads(content)
|
||||
|
||||
# Extract frontmatter fields
|
||||
slug = post.get("slug", file_path.stem)
|
||||
title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
|
||||
menu_order = post.get("menu_order", 100)
|
||||
|
||||
# Convert markdown to HTML
|
||||
self._md.reset()
|
||||
content_html = self._md.convert(post.content)
|
||||
|
||||
return CustomPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
menu_order=menu_order,
|
||||
content_html=content_html,
|
||||
file_path=str(file_path),
|
||||
)
|
||||
|
||||
def get_page(self, slug: str) -> Optional[CustomPage]:
|
||||
"""Get a page by its slug.
|
||||
|
||||
Args:
|
||||
slug: The page slug.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if not found.
|
||||
"""
|
||||
return self._pages.get(slug)
|
||||
|
||||
def get_menu_pages(self) -> list[CustomPage]:
|
||||
"""Get all pages sorted by menu_order for navigation.
|
||||
|
||||
Returns:
|
||||
List of CustomPage instances sorted by menu_order.
|
||||
"""
|
||||
return sorted(self._pages.values(), key=lambda p: (p.menu_order, p.title))
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload all pages from disk."""
|
||||
self.load_pages()
|
||||
@@ -3,23 +3,27 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.web.routes.home import router as home_router
|
||||
from meshcore_hub.web.routes.network import router as network_router
|
||||
from meshcore_hub.web.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.web.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
from meshcore_hub.web.routes.pages import router as pages_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(network_router)
|
||||
web_router.include_router(dashboard_router)
|
||||
web_router.include_router(nodes_router)
|
||||
web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
web_router.include_router(pages_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
|
||||
591
src/meshcore_hub/web/routes/admin.py
Normal file
591
src/meshcore_hub/web/routes/admin.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""Admin page routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from httpx import Response
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
|
||||
def _build_redirect_url(
|
||||
public_key: str,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL with optional message/error."""
|
||||
params: dict[str, str] = {"public_key": public_key}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
return f"/a/node-tags?{urlencode(params)}"
|
||||
|
||||
|
||||
def _get_error_detail(response: Response) -> str:
|
||||
"""Safely extract error detail from response JSON."""
|
||||
try:
|
||||
data: Any = response.json()
|
||||
detail: str = data.get("detail", "Unknown error")
|
||||
return detail
|
||||
except Exception:
|
||||
return "Unknown error"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/a", tags=["admin"])
|
||||
|
||||
|
||||
def _check_admin_enabled(request: Request) -> None:
|
||||
"""Check if admin interface is enabled, raise 404 if not."""
|
||||
if not getattr(request.app.state, "admin_enabled", False):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
def _get_auth_context(request: Request) -> dict:
|
||||
"""Extract OAuth2Proxy authentication headers."""
|
||||
return {
|
||||
"auth_user": request.headers.get("X-Forwarded-User"),
|
||||
"auth_groups": request.headers.get("X-Forwarded-Groups"),
|
||||
"auth_email": request.headers.get("X-Forwarded-Email"),
|
||||
"auth_username": request.headers.get("X-Forwarded-Preferred-Username"),
|
||||
}
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""Check if user is authenticated via OAuth2Proxy headers."""
|
||||
return bool(
|
||||
request.headers.get("X-Forwarded-User")
|
||||
or request.headers.get("X-Forwarded-Email")
|
||||
)
|
||||
|
||||
|
||||
def _require_auth(request: Request) -> None:
|
||||
"""Require authentication, raise 403 if not authenticated."""
|
||||
if not _is_authenticated(request):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home(request: Request) -> HTMLResponse:
|
||||
"""Render the admin page with OAuth2Proxy user info."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("admin/index.html", context)
|
||||
|
||||
|
||||
@router.get("/node-tags", response_class=HTMLResponse)
|
||||
async def admin_node_tags(
|
||||
request: Request,
|
||||
public_key: Optional[str] = Query(None),
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing node tags."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all nodes for dropdown
|
||||
nodes = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes",
|
||||
params={"limit": 100},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
# Sort nodes alphabetically by name (unnamed nodes at the end)
|
||||
nodes.sort(
|
||||
key=lambda n: (n.get("name") is None, (n.get("name") or "").lower())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch nodes: %s", e)
|
||||
context["error"] = "Failed to fetch nodes"
|
||||
|
||||
context["nodes"] = nodes
|
||||
context["selected_public_key"] = public_key
|
||||
|
||||
# Fetch tags for selected node
|
||||
tags = []
|
||||
selected_node = None
|
||||
if public_key:
|
||||
# Find the selected node in the list
|
||||
for node in nodes:
|
||||
if node.get("public_key") == public_key:
|
||||
selected_node = node
|
||||
break
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
tags = response.json()
|
||||
elif response.status_code == 404:
|
||||
context["error"] = "Node not found"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch tags: %s", e)
|
||||
context["error"] = "Failed to fetch tags"
|
||||
|
||||
context["tags"] = tags
|
||||
context["selected_node"] = selected_node
|
||||
|
||||
return templates.TemplateResponse("admin/node_tags.html", context)
|
||||
|
||||
|
||||
@router.post("/node-tags", response_class=RedirectResponse)
|
||||
async def admin_create_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
json={
|
||||
"key": key,
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(public_key, error="Node not found")
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to create tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/update", response_class=RedirectResponse)
|
||||
async def admin_update_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
json={
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to update tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/move", response_class=RedirectResponse)
|
||||
async def admin_move_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
new_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Move a node tag to a different node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}/move",
|
||||
json={"new_public_key": new_public_key},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
# Redirect to the destination node after successful move
|
||||
redirect_url = _build_redirect_url(
|
||||
new_public_key, message=f"Tag '{key}' moved successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
# Stay on source node if not found
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists on destination node"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to move tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to move tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/copy-all", response_class=RedirectResponse)
|
||||
async def admin_copy_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
dest_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Copy all tags from one node to another."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
copied = data.get("copied", 0)
|
||||
skipped = data.get("skipped", 0)
|
||||
if skipped > 0:
|
||||
message = f"Copied {copied} tag(s), skipped {skipped} existing"
|
||||
else:
|
||||
message = f"Copied {copied} tag(s) successfully"
|
||||
# Redirect to destination node to show copied tags
|
||||
redirect_url = _build_redirect_url(dest_public_key, message=message)
|
||||
elif response.status_code == 400:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to copy tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to copy tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete-all", response_class=RedirectResponse)
|
||||
async def admin_delete_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete all tags from a node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
deleted = data.get("deleted", 0)
|
||||
message = f"Deleted {deleted} tag(s) successfully"
|
||||
redirect_url = _build_redirect_url(public_key, message=message)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
def _build_members_redirect_url(
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL for members page with optional message/error."""
|
||||
params: dict[str, str] = {}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
if params:
|
||||
return f"/a/members?{urlencode(params)}"
|
||||
return "/a/members"
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def admin_members(
|
||||
request: Request,
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing members."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all members
|
||||
members = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members",
|
||||
params={"limit": 500},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
# Sort members alphabetically by name
|
||||
members.sort(key=lambda m: m.get("name", "").lower())
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch members: %s", e)
|
||||
context["error"] = "Failed to fetch members"
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("admin/members.html", context)
|
||||
|
||||
|
||||
@router.post("/members", response_class=RedirectResponse)
|
||||
async def admin_create_member(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
member_id: str = Form(...),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build request payload
|
||||
payload = {
|
||||
"name": name,
|
||||
"member_id": member_id,
|
||||
}
|
||||
if callsign:
|
||||
payload["callsign"] = callsign
|
||||
if role:
|
||||
payload["role"] = role
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if contact:
|
||||
payload["contact"] = contact
|
||||
|
||||
response = await request.app.state.http_client.post(
|
||||
"/api/v1/members",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message=f"Member '{name}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to create member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/update", response_class=RedirectResponse)
|
||||
async def admin_update_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
name: Optional[str] = Form(None),
|
||||
member_id: Optional[str] = Form(None),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build update payload (only include non-None fields)
|
||||
payload: dict[str, str | None] = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
if member_id is not None:
|
||||
payload["member_id"] = member_id
|
||||
if callsign is not None:
|
||||
payload["callsign"] = callsign if callsign else None
|
||||
if role is not None:
|
||||
payload["role"] = role if role else None
|
||||
if description is not None:
|
||||
payload["description"] = description if description else None
|
||||
if contact is not None:
|
||||
payload["contact"] = contact if contact else None
|
||||
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/members/{id}",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to update member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/members/{id}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to delete member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
@@ -14,7 +14,9 @@ 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"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
public_key: str | None = Query(None, description="Filter by node public key"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -28,14 +30,43 @@ async def advertisements_list(
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
if public_key:
|
||||
params["public_key"] = public_key
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
total = 0
|
||||
members = []
|
||||
nodes = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
# Fetch nodes for dropdown
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if nodes_response.status_code == 200:
|
||||
nodes = nodes_response.json().get("items", [])
|
||||
|
||||
# Sort nodes alphabetically by display name
|
||||
def get_node_display_name(node: dict) -> str:
|
||||
for tag in node.get("tags") or []:
|
||||
if tag.get("key") == "name":
|
||||
return str(tag.get("value", "")).lower()
|
||||
return str(node.get("name") or node.get("public_key", "")).lower()
|
||||
|
||||
nodes.sort(key=get_node_display_name)
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params=params
|
||||
)
|
||||
@@ -57,7 +88,11 @@ async def advertisements_list(
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"member_id": member_id or "",
|
||||
"public_key": public_key or "",
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Network overview page route."""
|
||||
"""Dashboard page route."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/network", response_class=HTMLResponse)
|
||||
async def network_overview(request: Request) -> HTMLResponse:
|
||||
"""Render the network overview page."""
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Render the dashboard page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
@@ -76,4 +76,4 @@ async def network_overview(request: Request) -> HTMLResponse:
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
context["node_count_json"] = json.dumps(node_count)
|
||||
|
||||
return templates.TemplateResponse("network.html", context)
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
@@ -29,8 +29,9 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"advertisements_24h": 0,
|
||||
}
|
||||
|
||||
# Fetch activity data for chart
|
||||
activity = {"days": 7, "data": []}
|
||||
# Fetch activity data for charts
|
||||
advert_activity = {"days": 7, "data": []}
|
||||
message_activity = {"days": 7, "data": []}
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
|
||||
@@ -45,12 +46,22 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"/api/v1/dashboard/activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
activity = response.json()
|
||||
advert_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch activity from API: {e}")
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/message-activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
message_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch message activity from API: {e}")
|
||||
|
||||
context["stats"] = stats
|
||||
# Pass activity data as JSON string for the chart
|
||||
context["activity_json"] = json.dumps(activity)
|
||||
# Pass activity data as JSON strings for the chart
|
||||
context["advert_activity_json"] = json.dumps(advert_activity)
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
|
||||
return templates.TemplateResponse("home.html", context)
|
||||
|
||||
@@ -30,28 +30,27 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"""
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_key: dict[str, dict[str, Any]] = {}
|
||||
members_by_id: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by public_key
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
# Only include members with public_key (required for node ownership)
|
||||
if member.get("public_key"):
|
||||
member_info = {
|
||||
"public_key": member.get("public_key"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
members_by_key[member["public_key"]] = member_info
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch members: status {members_response.status_code}"
|
||||
@@ -73,6 +72,7 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
@@ -90,6 +90,8 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
nodes_with_coords += 1
|
||||
@@ -101,8 +103,10 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
|
||||
# Find owner member if exists
|
||||
owner = members_by_key.get(public_key)
|
||||
# Find owner member by member_id tag
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
@@ -114,6 +118,7 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,6 @@ router = APIRouter()
|
||||
async def messages_list(
|
||||
request: Request,
|
||||
message_type: str | None = Query(None, description="Filter by message type"),
|
||||
channel_idx: str | None = Query(None, description="Filter by channel"),
|
||||
search: str | None = Query(None, description="Search in message text"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
@@ -28,20 +27,10 @@ async def messages_list(
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Parse channel_idx, treating empty string as None
|
||||
channel_idx_int: int | None = None
|
||||
if channel_idx and channel_idx.strip():
|
||||
try:
|
||||
channel_idx_int = int(channel_idx)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid channel_idx value: {channel_idx}")
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if message_type:
|
||||
params["message_type"] = message_type
|
||||
if channel_idx_int is not None:
|
||||
params["channel_idx"] = channel_idx_int
|
||||
|
||||
# Fetch messages from API
|
||||
messages = []
|
||||
@@ -70,7 +59,6 @@ async def messages_list(
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"message_type": message_type or "",
|
||||
"channel_idx": channel_idx_int,
|
||||
"search": search or "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
@@ -16,6 +16,7 @@ async def nodes_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
adv_type: str | None = Query(None, description="Filter by node type"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -33,12 +34,22 @@ async def nodes_list(
|
||||
params["search"] = search
|
||||
if adv_type:
|
||||
params["adv_type"] = adv_type
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
members = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params=params
|
||||
)
|
||||
@@ -62,31 +73,53 @@ async def nodes_list(
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"adv_type": adv_type or "",
|
||||
"member_id": member_id or "",
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
|
||||
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
"""Render the node detail page."""
|
||||
@router.get("/n/{prefix}")
|
||||
async def node_short_link(prefix: str) -> RedirectResponse:
|
||||
"""Redirect short link to nodes page."""
|
||||
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_model=None)
|
||||
async def node_detail(
|
||||
request: Request, public_key: str
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
"""Render the node detail page.
|
||||
|
||||
If the key is not a full 64-character public key, uses the prefix API
|
||||
to resolve it and redirects to the canonical URL.
|
||||
"""
|
||||
# If not a full public key, resolve via prefix API and redirect
|
||||
if len(public_key) != 64:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/prefix/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
node = None
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
try:
|
||||
# Fetch node details
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
# Fetch node details (exact match)
|
||||
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node = response.json()
|
||||
|
||||
try:
|
||||
# Fetch recent advertisements for this node
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params={"public_key": public_key, "limit": 10}
|
||||
@@ -105,12 +138,18 @@ async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
logger.warning(f"Failed to fetch node details from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Check if admin editing is available
|
||||
admin_enabled = getattr(request.app.state, "admin_enabled", False)
|
||||
auth_user = request.headers.get("X-Forwarded-User")
|
||||
|
||||
context.update(
|
||||
{
|
||||
"node": node,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"public_key": public_key,
|
||||
"admin_enabled": admin_enabled,
|
||||
"is_authenticated": bool(auth_user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
36
src/meshcore_hub/web/routes/pages.py
Normal file
36
src/meshcore_hub/web/routes/pages.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Custom pages route for MeshCore Hub Web Dashboard."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
router = APIRouter(tags=["Pages"])
|
||||
|
||||
|
||||
@router.get("/pages/{slug}", response_class=HTMLResponse)
|
||||
async def custom_page(request: Request, slug: str) -> HTMLResponse:
|
||||
"""Render a custom markdown page.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
slug: The page slug from the URL.
|
||||
|
||||
Returns:
|
||||
Rendered HTML page.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if page not found.
|
||||
"""
|
||||
page_loader = request.app.state.page_loader
|
||||
page = page_loader.get_page(slug)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["page"] = page
|
||||
|
||||
return templates.TemplateResponse("page.html", context)
|
||||
61
src/meshcore_hub/web/static/img/logo.svg
Normal file
61
src/meshcore_hub/web/static/img/logo.svg
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 115 100"
|
||||
width="115"
|
||||
height="100"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="logo-dark.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- I letter - muted -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
opacity="0.5"
|
||||
id="rect1" />
|
||||
<!-- P vertical stem -->
|
||||
<rect
|
||||
x="35"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
id="rect2" />
|
||||
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
id="g4"
|
||||
transform="translate(-30,-10)">
|
||||
<path
|
||||
d="M 110,65 A 20,20 0 0 0 90,45"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M 125,65 A 35,35 0 0 0 90,30"
|
||||
id="path3" />
|
||||
<path
|
||||
d="M 140,65 A 50,50 0 0 0 90,15"
|
||||
id="path4" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 134 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M3.277,0.053C2.829,0.053 2.401,0.41 2.321,0.851L0.013,13.623C-0.067,14.064 0.232,14.421 0.681,14.421L3.13,14.421C3.578,14.421 4.006,14.064 4.086,13.623L5.004,8.54L6.684,13.957C6.766,14.239 7.02,14.421 7.337,14.421L10.58,14.421C10.897,14.421 11.217,14.239 11.401,13.957L15.043,8.513L14.119,13.623C14.038,14.064 14.338,14.421 14.787,14.421L17.236,14.421C17.684,14.421 18.112,14.064 18.192,13.623L20.5,0.851C20.582,0.41 20.283,0.053 19.834,0.053L16.69,0.053C16.373,0.053 16.053,0.235 15.87,0.517L9.897,9.473C9.803,9.616 9.578,9.578 9.528,9.41L7.074,0.517C6.992,0.235 6.738,0.053 6.421,0.053L3.277,0.053Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M21.146,14.421C21.146,14.421 33.257,14.421 33.257,14.421C33.526,14.421 33.784,14.205 33.831,13.942L34.337,11.128C34.385,10.863 34.206,10.649 33.936,10.649L25.519,10.649C25.429,10.649 25.37,10.576 25.385,10.488L25.635,9.105C25.65,9.017 25.736,8.944 25.826,8.944L32.596,8.944C32.865,8.944 33.123,8.728 33.171,8.465L33.621,5.974C33.669,5.709 33.49,5.495 33.221,5.495L26.45,5.495C26.361,5.495 26.301,5.423 26.317,5.335L26.584,3.852C26.599,3.764 26.685,3.691 26.775,3.691L35.192,3.691C35.462,3.691 35.719,3.476 35.767,3.21L36.258,0.498C36.306,0.235 36.126,0.019 35.857,0.019L23.746,0.019C23.297,0.019 22.867,0.378 22.788,0.819L20.474,13.621C20.396,14.062 20.695,14.421 21.146,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M45.926,14.419L45.926,14.421L46.346,14.421C48.453,14.421 50.465,12.742 50.839,10.67L51.081,9.327C51.456,7.256 50.05,5.576 47.943,5.576L41.455,5.576C41.186,5.576 41.007,5.363 41.054,5.097L41.218,4.192C41.266,3.927 41.524,3.713 41.793,3.713L50.569,3.713C51.018,3.713 51.446,3.356 51.526,2.915L51.9,0.85C51.98,0.407 51.68,0.05 51.232,0.05L41.638,0.05C39.531,0.05 37.519,1.73 37.145,3.801L36.88,5.267C36.505,7.339 37.91,9.018 40.018,9.018L46.506,9.018C46.775,9.018 46.954,9.231 46.907,9.497L46.785,10.176C46.737,10.441 46.479,10.655 46.21,10.655L37.189,10.655C36.741,10.655 36.313,11.012 36.233,11.453L35.841,13.621C35.761,14.062 36.061,14.419 36.51,14.419L45.926,14.419Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M68.008,0.046C68.008,0.046 65.296,0.046 65.296,0.046C64.847,0.046 64.42,0.403 64.34,0.844L63.532,5.31C63.517,5.398 63.431,5.469 63.341,5.469L58.085,5.469C57.995,5.469 57.936,5.398 57.951,5.31L58.758,0.844C58.837,0.403 58.539,0.046 58.09,0.046L55.378,0.046C54.93,0.046 54.502,0.403 54.422,0.844L52.112,13.623C52.032,14.064 52.331,14.421 52.78,14.421L55.492,14.421C55.941,14.421 56.369,14.064 56.449,13.623L57.272,9.074C57.287,8.986 57.373,8.914 57.462,8.914L62.719,8.914C62.809,8.914 62.868,8.985 62.853,9.074L62.032,13.623C61.952,14.064 62.252,14.421 62.7,14.421L65.413,14.421C65.861,14.421 66.289,14.064 66.369,13.623L68.678,0.844C68.755,0.403 68.457,0.046 68.008,0.046Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M72.099,14.421C72.099,14.421 80.066,14.421 80.066,14.421C80.515,14.421 80.943,14.064 81.022,13.623L81.414,11.453C81.494,11.012 81.194,10.655 80.746,10.655L73.828,10.655C73.559,10.655 73.38,10.441 73.427,10.176L74.51,4.215C74.558,3.951 74.815,3.736 75.082,3.736L82,3.736C82.448,3.736 82.876,3.379 82.956,2.938L83.34,0.817C83.42,0.376 83.12,0.019 82.672,0.019L74.724,0.019C72.622,0.019 70.614,1.691 70.236,3.757L68.965,10.665C68.587,12.738 69.99,14.421 72.099,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M97.176,-0C97.176,0 88.882,0 88.882,0C86.775,0 84.763,1.68 84.389,3.751L83.139,10.67C82.765,12.741 84.169,14.421 86.277,14.421L94.571,14.421C96.678,14.421 98.69,12.741 99.064,10.67L100.314,3.751C100.689,1.68 99.284,-0 97.176,-0ZM94.798,10.178C94.75,10.443 94.492,10.657 94.223,10.657L87.978,10.657C87.709,10.657 87.529,10.443 87.577,10.178L88.659,4.192C88.707,3.927 88.964,3.713 89.234,3.713L95.477,3.713C95.747,3.713 95.926,3.927 95.878,4.192L94.798,10.178Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M101.284,14.421L103.995,14.421C104.443,14.421 104.871,14.065 104.951,13.624L105.43,10.97C105.446,10.882 105.531,10.81 105.621,10.81L108.902,10.806C109.064,10.806 109.2,10.886 109.267,11.018L110.813,14.035C110.992,14.392 111.319,14.434 112.303,14.419C112.88,14.426 113.756,14.382 115.169,14.382C115.623,14.382 115.902,13.907 115.678,13.51L113.989,10.569C113.945,10.491 113.993,10.386 114.086,10.34C115.39,9.707 116.423,8.477 116.681,7.055L117.27,3.785C117.646,1.713 116.242,0.033 114.134,0.033L103.884,0.033C103.436,0.033 103.008,0.39 102.928,0.831L100.616,13.623C100.536,14.064 100.836,14.421 101.284,14.421L101.284,14.421ZM106.73,3.791C106.745,3.703 106.831,3.631 106.921,3.631L112.225,3.631C112.626,3.631 112.891,3.949 112.821,4.343L112.431,6.494C112.359,6.885 111.979,7.204 111.58,7.204L106.276,7.204C106.186,7.204 106.127,7.133 106.142,7.043L106.73,3.791Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M118.277,14.421C118.277,14.421 130.388,14.421 130.388,14.421C130.657,14.421 130.915,14.205 130.963,13.942L131.468,11.128C131.516,10.863 131.337,10.649 131.068,10.649L122.65,10.649C122.56,10.649 122.501,10.576 122.516,10.488L122.766,9.105C122.781,9.017 122.867,8.944 122.957,8.944L129.728,8.944C129.997,8.944 130.254,8.728 130.302,8.465L130.753,5.974C130.801,5.709 130.621,5.495 130.352,5.495L123.581,5.495C123.492,5.495 123.432,5.423 123.448,5.335L123.715,3.852C123.73,3.764 123.816,3.691 123.906,3.691L132.324,3.691C132.593,3.691 132.851,3.476 132.898,3.21L133.389,0.498C133.437,0.235 133.257,0.019 132.988,0.019L120.877,0.019C120.428,0.019 119.999,0.378 119.919,0.819L117.605,13.621C117.527,14.062 117.827,14.421 118.277,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
78
src/meshcore_hub/web/static/js/utils.js
Normal file
78
src/meshcore_hub/web/static/js/utils.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* MeshCore Hub - Common JavaScript Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object
|
||||
* @returns {string} Relative time string, or empty string if invalid
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate all elements with data-timestamp attribute with relative time
|
||||
*/
|
||||
function populateRelativeTimestamps() {
|
||||
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
|
||||
const timestamp = el.dataset.timestamp;
|
||||
if (timestamp) {
|
||||
el.textContent = formatRelativeTime(timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate receiver tooltip elements with name and relative time
|
||||
*/
|
||||
function populateReceiverTooltips() {
|
||||
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
const timestamp = el.dataset.timestamp;
|
||||
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
|
||||
|
||||
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
|
||||
let tooltip = name;
|
||||
if (relTime) {
|
||||
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
|
||||
}
|
||||
el.title = tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate <time> elements with data-relative-time attribute
|
||||
* Uses the datetime attribute as the timestamp source
|
||||
*/
|
||||
function populateRelativeTimeElements() {
|
||||
document.querySelectorAll('time[data-relative-time]').forEach(el => {
|
||||
const timestamp = el.getAttribute('datetime');
|
||||
if (timestamp) {
|
||||
const relTime = formatRelativeTime(timestamp);
|
||||
el.textContent = relTime ? `${relTime} ago` : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRelativeTimestamps();
|
||||
populateReceiverTooltips();
|
||||
populateRelativeTimeElements();
|
||||
});
|
||||
47
src/meshcore_hub/web/templates/_macros.html
Normal file
47
src/meshcore_hub/web/templates/_macros.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{# Reusable macros for templates #}
|
||||
|
||||
{#
|
||||
Pagination macro
|
||||
|
||||
Parameters:
|
||||
- page: Current page number
|
||||
- total_pages: Total number of pages
|
||||
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
|
||||
#}
|
||||
{% macro pagination(page, total_pages, params={}) %}
|
||||
{% if total_pages > 1 %}
|
||||
{% set query_parts = [] %}
|
||||
{% for key, value in params.items() %}
|
||||
{% if value is not none and value != '' %}
|
||||
{% set _ = query_parts.append(key ~ '=' ~ value) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set base_query = query_parts|join('&') %}
|
||||
{% set query_prefix = '&' if base_query else '' %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
20
src/meshcore_hub/web/templates/admin/access_denied.html
Normal file
20
src/meshcore_hub/web/templates/admin/access_denied.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
|
||||
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">Return Home</a>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
src/meshcore_hub/web/templates/admin/index.html
Normal file
70
src/meshcore_hub/web/templates/admin/index.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li>Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated User Info -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
|
||||
{% if auth_username or auth_user %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ auth_username or auth_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if auth_email %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ auth_email }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Navigation Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
Members
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
Node Tags
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
282
src/meshcore_hub/web/templates/admin/members.html
Normal file
282
src/meshcore_hub/web/templates/admin/members.html
Normal file
@@ -0,0 +1,282 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Members</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">Network Members ({{ members|length }})</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="addModal.showModal()">Add Member</button>
|
||||
</div>
|
||||
|
||||
{% if members %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member ID</th>
|
||||
<th>Name</th>
|
||||
<th>Callsign</th>
|
||||
<th>Contact</th>
|
||||
<th class="w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr data-member-id="{{ member.id }}"
|
||||
data-member-name="{{ member.name }}"
|
||||
data-member-member-id="{{ member.member_id }}"
|
||||
data-member-callsign="{{ member.callsign or '' }}"
|
||||
data-member-description="{{ member.description or '' }}"
|
||||
data-member-contact="{{ member.contact or '' }}">
|
||||
<td class="font-mono font-semibold">{{ member.member_id }}</td>
|
||||
<td>{{ member.name }}</td>
|
||||
<td>
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-primary">{{ member.callsign }}</span>
|
||||
{% else %}
|
||||
<span class="text-base-content/40">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title="{{ member.contact or '' }}">{{ member.contact or '-' }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No members configured yet.</p>
|
||||
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<dialog id="addModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Add New Member</h3>
|
||||
<form method="post" action="/a/members" class="py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="add_member_id" class="input input-bordered"
|
||||
placeholder="walshie86" required maxlength="50"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="add_name" class="input input-bordered"
|
||||
placeholder="John Smith" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" id="add_callsign" class="input input-bordered"
|
||||
placeholder="VK4ABC" maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" id="add_contact" class="input input-bordered"
|
||||
placeholder="john@example.com or phone number" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" id="add_description" rows="3" class="textarea textarea-bordered"
|
||||
placeholder="Brief description of member's role and responsibilities..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="addModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Edit Member</h3>
|
||||
<form method="post" action="/a/members/update" class="py-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
|
||||
required maxlength="50" pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="edit_name" class="input input-bordered"
|
||||
required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
|
||||
maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" id="edit_description" rows="3"
|
||||
class="textarea textarea-bordered"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Member</h3>
|
||||
<form method="post" action="/a/members/delete" class="py-4">
|
||||
<input type="hidden" name="id" id="delete_id">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('edit_id').value = row.dataset.memberId;
|
||||
document.getElementById('edit_member_id').value = row.dataset.memberMemberId;
|
||||
document.getElementById('edit_name').value = row.dataset.memberName;
|
||||
document.getElementById('edit_callsign').value = row.dataset.memberCallsign;
|
||||
document.getElementById('edit_description').value = row.dataset.memberDescription;
|
||||
document.getElementById('edit_contact').value = row.dataset.memberContact;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('delete_id').value = row.dataset.memberId;
|
||||
document.getElementById('delete_member_name').textContent = row.dataset.memberName;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
434
src/meshcore_hub/web/templates/admin/node_tags.html
Normal file
434
src/meshcore_hub/web/templates/admin/node_tags.html
Normal file
@@ -0,0 +1,434 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Node Tags</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Node Tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Selector -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Select Node</h2>
|
||||
<form method="get" action="/a/node-tags" class="flex gap-4 items-end">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<option value="">-- Select a node --</option>
|
||||
{% for node in nodes %}
|
||||
<option value="{{ node.public_key }}" {% if node.public_key == selected_public_key %}selected{% endif %}>
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Load Tags</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_public_key and selected_node %}
|
||||
<!-- Selected Node Info -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl" title="{{ selected_node.adv_type or 'Unknown' }}">{% if selected_node.adv_type and selected_node.adv_type|lower == 'chat' %}💬{% elif selected_node.adv_type and selected_node.adv_type|lower == 'repeater' %}📡{% elif selected_node.adv_type and selected_node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
|
||||
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if tags %}
|
||||
<button class="btn btn-outline btn-sm" onclick="copyAllModal.showModal()">Copy All</button>
|
||||
<button class="btn btn-outline btn-error btn-sm" onclick="deleteAllModal.showModal()">Delete All</button>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Table -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags ({{ tags|length }})</h2>
|
||||
|
||||
{% if tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th class="w-48">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in tags %}
|
||||
<tr data-tag-key="{{ tag.key }}" data-tag-value="{{ tag.value or '' }}" data-tag-type="{{ tag.value_type }}">
|
||||
<td class="font-mono font-semibold">{{ tag.key }}</td>
|
||||
<td class="max-w-xs truncate" title="{{ tag.value or '' }}">{{ tag.value or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at[:10] if tag.updated_at else '-' }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">
|
||||
Move
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No tags found for this node.</p>
|
||||
<p class="text-sm mt-2">Add a new tag below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Tag Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Add New Tag</h2>
|
||||
<form method="post" action="/a/node-tags" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" class="select select-bordered">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text"> </span>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Add Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<form method="post" action="/a/node-tags/update" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="editKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" id="editValue" class="input input-bordered">
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" id="editValueType" class="select select-bordered w-full">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Move Modal -->
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/move" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="moveKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Tag Key</span>
|
||||
</label>
|
||||
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="new_public_key" id="moveDestination" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This will move the tag from the current node to the destination node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="moveModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Move Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Tag</h3>
|
||||
<form method="post" action="/a/node-tags/delete" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="deleteKey">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Copy All Tags Modal -->
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/copy-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Copy all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong> to another node.</p>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="dest_public_key" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="copyAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Copy Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete All Tags Modal -->
|
||||
<dialog id="deleteAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete All Tags</h3>
|
||||
<form method="post" action="/a/node-tags/delete-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete All Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% elif selected_public_key and not selected_node %}
|
||||
<div class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Node not found: {{ selected_public_key }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
|
||||
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
var value = row.dataset.tagValue;
|
||||
var valueType = row.dataset.tagType;
|
||||
document.getElementById('editKey').value = key;
|
||||
document.getElementById('editKeyDisplay').value = key;
|
||||
document.getElementById('editValue').value = value;
|
||||
document.getElementById('editValueType').value = valueType;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Move button handler
|
||||
document.querySelectorAll('.btn-move').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('moveKey').value = key;
|
||||
document.getElementById('moveKeyDisplay').value = key;
|
||||
document.getElementById('moveDestination').selectedIndex = 0;
|
||||
moveModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('deleteKey').value = key;
|
||||
document.getElementById('deleteKeyDisplay').textContent = key;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Advertisements{% endblock %}
|
||||
|
||||
@@ -23,130 +24,140 @@
|
||||
<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>
|
||||
{% if nodes %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm">
|
||||
<option value="">All Nodes</option>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table -->
|
||||
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<!-- Advertisements List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for ad in advertisements %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No advertisements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in advertisements %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
{% if ad.node_friendly_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_friendly_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ ad.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in ad.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif ad.receivers and ad.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ ad.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if ad.receivers[0].friendly_name or ad.receivers[0].name %}
|
||||
<div class="font-medium">{{ ad.receivers[0].friendly_name or ad.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
|
||||
{% if ad.receiver_friendly_name or ad.receiver_name %}
|
||||
<div class="font-medium">{{ ad.receiver_friendly_name or ad.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&public_key={{ public_key }}&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>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -5,6 +6,27 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ network_name }}{% endblock %}</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
{% set default_description = network_name ~ " - MeshCore off-grid LoRa mesh network dashboard. Monitor nodes, messages, and network activity." %}
|
||||
<meta name="description" content="{% block meta_description %}{{ default_description }}{% endblock %}">
|
||||
<meta name="generator" content="MeshCore Hub {{ version }}">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
<meta property="og:site_name" content="{{ network_name }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{% block twitter_title %}{{ self.title() }}{% endblock %}">
|
||||
<meta name="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/logo.svg">
|
||||
|
||||
<!-- Tailwind CSS with DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -40,6 +62,28 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Prose styling for custom markdown pages */
|
||||
.prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; }
|
||||
.prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; }
|
||||
.prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||
.prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||
.prose p { margin-bottom: 1rem; line-height: 1.75; }
|
||||
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
|
||||
.prose ul { list-style-type: disc; }
|
||||
.prose ol { list-style-type: decimal; }
|
||||
.prose li { margin-bottom: 0.25rem; }
|
||||
.prose a { color: oklch(var(--p)); text-decoration: underline; }
|
||||
.prose a:hover { color: oklch(var(--pf)); }
|
||||
.prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
|
||||
.prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; }
|
||||
.prose pre code { background: none; padding: 0; }
|
||||
.prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; }
|
||||
.prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; }
|
||||
.prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; }
|
||||
.prose th { background: oklch(var(--b2)); font-weight: 600; }
|
||||
.prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; }
|
||||
.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
@@ -55,31 +99,35 @@
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
|
||||
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">Advertisements</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
|
||||
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost text-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
|
||||
{{ network_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
|
||||
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">Advertisements</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
|
||||
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ icon_page("h-4 w-4") }} {{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
@@ -114,13 +162,16 @@
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Common utilities -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,
|
||||
35
src/meshcore_hub/web/templates/errors/404.html
Normal file
35
src/meshcore_hub/web/templates/errors/404.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero min-h-[60vh]">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-9xl font-bold text-primary opacity-20">404</div>
|
||||
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
|
||||
<p class="py-6 text-base-content/70">
|
||||
{% if detail %}
|
||||
{{ detail }}
|
||||
{% else %}
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Go Home
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,101 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero py-8 bg-base-100 rounded-box">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-4 justify-center flex-wrap">
|
||||
<a href="/network" 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="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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
Messages
|
||||
</a>
|
||||
<!-- Hero Section with Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
|
||||
<!-- Hero Content (2 columns) -->
|
||||
<div class="lg:col-span-2 flex flex-col items-center text-center">
|
||||
<!-- Header: Logo and Title side by side -->
|
||||
<div class="flex items-center gap-8 mb-4">
|
||||
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-36 w-36" />
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="text-6xl font-black tracking-tight">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="text-2xl opacity-70 mt-2">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-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>
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4 max-w-[70%]">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4 max-w-[70%]">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex flex-wrap justify-center gap-3 mt-auto">
|
||||
<a href="/dashboard" class="btn btn-outline btn-info">
|
||||
{{ icon_dashboard("h-5 w-5 mr-2") }}
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline btn-primary">
|
||||
{{ icon_nodes("h-5 w-5 mr-2") }}
|
||||
Nodes
|
||||
</a>
|
||||
<a href="/advertisements" class="btn btn-outline btn-secondary">
|
||||
{{ icon_advertisements("h-5 w-5 mr-2") }}
|
||||
Adverts
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-outline btn-accent">
|
||||
{{ icon_messages("h-5 w-5 mr-2") }}
|
||||
Messages
|
||||
</a>
|
||||
<a href="/map" class="btn btn-outline btn-warning">
|
||||
{{ icon_map("h-5 w-5 mr-2") }}
|
||||
Map
|
||||
</a>
|
||||
</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 (24h) -->
|
||||
<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>
|
||||
<!-- Stats Column (stacked vertically) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-primary">
|
||||
{{ icon_nodes("h-8 w-8") }}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<!-- Total Messages -->
|
||||
<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>
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-secondary">
|
||||
{{ icon_advertisements("h-8 w-8") }}
|
||||
</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>
|
||||
<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>
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-accent">
|
||||
{{ icon_messages("h-8 w-8") }}
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
@@ -123,18 +109,18 @@
|
||||
<span class="font-mono">{{ network_radio_config.frequency }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.spreading_factor %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Spreading Factor:</span>
|
||||
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.bandwidth %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Bandwidth:</span>
|
||||
<span class="font-mono">{{ network_radio_config.bandwidth }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.spreading_factor %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Spreading Factor:</span>
|
||||
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.coding_rate %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Coding Rate:</span>
|
||||
@@ -158,6 +144,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Powered by MeshCore -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body flex flex-col items-center justify-center">
|
||||
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="h-8" />
|
||||
</a>
|
||||
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
</a>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Activity Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -167,53 +178,12 @@
|
||||
</svg>
|
||||
Network Activity
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Advertisements received per day (last 7 days)</p>
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
<div class="h-48">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Contact
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||
</svg>
|
||||
{{ network_contact_email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not network_contact_email and not network_contact_discord and not network_contact_github %}
|
||||
<p class="text-sm opacity-70">No contact information configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -221,16 +191,18 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const activityData = {{ activity_json | safe }};
|
||||
const advertData = {{ advert_activity_json | safe }};
|
||||
const messageData = {{ message_activity_json | safe }};
|
||||
const ctx = document.getElementById('activityChart');
|
||||
|
||||
if (ctx && activityData.data && activityData.data.length > 0) {
|
||||
if (ctx && advertData.data && advertData.data.length > 0) {
|
||||
// Format dates for display (show only day/month)
|
||||
const labels = activityData.data.map(d => {
|
||||
const labels = advertData.data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
const counts = activityData.data.map(d => d.count);
|
||||
const advertCounts = advertData.data.map(d => d.count);
|
||||
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
@@ -238,10 +210,19 @@
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: counts,
|
||||
borderColor: 'oklch(0.7 0.15 250)',
|
||||
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
|
||||
fill: true,
|
||||
data: advertCounts,
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}, {
|
||||
label: 'Messages',
|
||||
data: messageCounts,
|
||||
borderColor: 'oklch(0.7 0.15 200)',
|
||||
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
@@ -252,7 +233,13 @@
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
|
||||
47
src/meshcore_hub/web/templates/macros/icons.html
Normal file
47
src/meshcore_hub/web/templates/macros/icons.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% macro icon_dashboard(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_map(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_nodes(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_advertisements(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_messages(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_home(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_members(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_page(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
@@ -45,19 +45,13 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Owner</span>
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select id="filter-owner" class="select select-bordered select-sm">
|
||||
<option value="">All Owners</option>
|
||||
<select id="filter-member" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Infrastructure Only</span>
|
||||
<input type="checkbox" id="filter-infra" class="checkbox checkbox-sm checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +82,10 @@
|
||||
<span class="text-lg">📍</span>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg" style="filter: drop-shadow(0 0 4px gold);">📡</span>
|
||||
<span>Infrastructure (gold glow)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags. Infrastructure nodes are tagged with <code>role: infra</code>.</p>
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -120,23 +110,7 @@
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Format relative time (e.g., "2m", "1h", "2d")
|
||||
function formatRelativeTime(lastSeenStr) {
|
||||
if (!lastSeenStr) return null;
|
||||
|
||||
const lastSeen = new Date(lastSeenStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - lastSeen;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
// formatRelativeTime is provided by /static/js/utils.js
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(node) {
|
||||
@@ -159,14 +133,13 @@
|
||||
// Create marker icon for a node
|
||||
function createNodeIcon(node) {
|
||||
const emoji = getNodeEmoji(node);
|
||||
const infraGlow = node.is_infra ? 'filter: drop-shadow(0 0 4px gold);' : '';
|
||||
const displayName = node.name || '';
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="display: flex; align-items: center; gap: 2px;">
|
||||
<span style="font-size: 24px; ${infraGlow} text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
|
||||
</div>`,
|
||||
iconSize: [82, 28],
|
||||
@@ -186,8 +159,7 @@
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
|
||||
}
|
||||
|
||||
const emoji = getNodeEmoji(node);
|
||||
@@ -218,20 +190,16 @@
|
||||
// Core filter logic - returns filtered nodes and updates markers
|
||||
function applyFiltersCore() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const ownerFilter = document.getElementById('filter-owner').value;
|
||||
const infraOnly = document.getElementById('filter-infra').checked;
|
||||
const memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// Infrastructure filter
|
||||
if (infraOnly && !node.is_infra) return false;
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter) {
|
||||
if (!node.owner || node.owner.public_key !== ownerFilter) return false;
|
||||
// Member filter - match node's member_id tag to selected member_id
|
||||
if (memberFilter) {
|
||||
if (node.member_id !== memberFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -281,47 +249,36 @@
|
||||
applyFiltersCore();
|
||||
}
|
||||
|
||||
// Populate owner filter dropdown
|
||||
function populateOwnerFilter() {
|
||||
const select = document.getElementById('filter-owner');
|
||||
// Populate member filter dropdown
|
||||
function populateMemberFilter() {
|
||||
const select = document.getElementById('filter-member');
|
||||
|
||||
// Get unique owners from nodes that have locations
|
||||
const ownersWithNodes = new Set();
|
||||
allNodes.forEach(node => {
|
||||
if (node.owner) {
|
||||
ownersWithNodes.add(node.owner.public_key);
|
||||
// Sort members by name
|
||||
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options for all members
|
||||
sortedMembers.forEach(member => {
|
||||
if (member.member_id) {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.member_id;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter members to only those who own nodes on the map
|
||||
const relevantMembers = allMembers.filter(m => ownersWithNodes.has(m.public_key));
|
||||
|
||||
// Sort by name
|
||||
relevantMembers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options
|
||||
relevantMembers.forEach(member => {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.public_key;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-owner').value = '';
|
||||
document.getElementById('filter-infra').checked = false;
|
||||
document.getElementById('filter-member').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-owner').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-infra').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
@@ -352,8 +309,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate owner filter
|
||||
populateOwnerFilter();
|
||||
// Populate member filter
|
||||
populateMemberFilter();
|
||||
|
||||
// Initial display - center map on nodes if available
|
||||
if (allNodes.length > 0) {
|
||||
|
||||
@@ -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' %}
|
||||
@@ -48,14 +50,17 @@
|
||||
📦
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
{% if node.friendly_name or node.node_name %}
|
||||
<div class="font-medium text-sm">{{ node.friendly_name or node.node_name }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if display_name %}
|
||||
<div class="font-medium text-sm">{{ display_name }}</div>
|
||||
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -80,18 +85,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 %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Messages{% endblock %}
|
||||
|
||||
@@ -27,59 +28,92 @@
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="direct" {% if message_type == 'direct' %}selected{% endif %}>Direct</option>
|
||||
<option value="contact" {% if message_type == 'contact' %}selected{% endif %}>Direct</option>
|
||||
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Channel</span>
|
||||
</label>
|
||||
<select name="channel_idx" class="select select-bordered select-sm">
|
||||
<option value="">All Channels</option>
|
||||
{% for i in range(8) %}
|
||||
<option value="{{ i }}" {% if channel_idx == i %}selected{% endif %}>Channel {{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Table -->
|
||||
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<!-- Messages List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for msg in messages %}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="opacity-60">Public</span>
|
||||
{% else %}
|
||||
{% if msg.sender_tag_name or msg.sender_name %}
|
||||
{{ msg.sender_tag_name or msg.sender_name }}
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm hover:opacity-70" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-sm hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">{{ msg.text or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No messages found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Messages Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>From/Channel</th>
|
||||
<th>From</th>
|
||||
<th>Message</th>
|
||||
<th>Receiver</th>
|
||||
<th>SNR</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr class="hover align-top">
|
||||
<td>
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="badge badge-info badge-sm">Channel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success badge-sm">Direct</span>
|
||||
{% endif %}
|
||||
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="font-mono">CH{{ msg.channel_idx }}</span>
|
||||
<span class="opacity-60">Public</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 %}
|
||||
@@ -87,89 +121,27 @@
|
||||
</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ msg.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in msg.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
<span class="flex-1">{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}</span>
|
||||
{% if recv.snr is not none %}
|
||||
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(recv.snr) }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.receivers and msg.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ msg.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if msg.receivers[0].friendly_name or msg.receivers[0].name %}
|
||||
<div class="font-medium">{{ msg.receivers[0].friendly_name or msg.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
|
||||
{% if msg.receiver_friendly_name or msg.receiver_name %}
|
||||
<div class="font-medium">{{ msg.receiver_friendly_name or msg.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center whitespace-nowrap">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
<td colspan="5" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"message_type": message_type, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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,64 @@
|
||||
</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 or (admin_enabled and is_authenticated) %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<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>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 +181,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 +231,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 +264,67 @@
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
// Initialize map centered on the node's location
|
||||
const nodeLat = {{ ns_map.lat }};
|
||||
const nodeLon = {{ ns_map.lon }};
|
||||
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
|
||||
const nodeType = {{ (node.adv_type or '') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
|
||||
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(type) {
|
||||
const normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
const emoji = getNodeEmoji(nodeType);
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
|
||||
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Nodes{% endblock %}
|
||||
|
||||
@@ -25,7 +26,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">
|
||||
@@ -38,55 +39,109 @@
|
||||
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table -->
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<!-- Nodes List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:10] }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 justify-end mt-1">
|
||||
{% for tag in node.tags[:2] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 2 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No nodes found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(friendly_name=none) %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'friendly_name' %}
|
||||
{% set ns.friendly_name = tag.value %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
|
||||
{% if ns.friendly_name or node.name %}
|
||||
<div class="font-medium">{{ ns.friendly_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if node.adv_type and node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif node.adv_type %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
@@ -111,39 +166,12 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
15
src/meshcore_hub/web/templates/page.html
Normal file
15
src/meshcore_hub/web/templates/page.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block meta_description %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body prose prose-lg max-w-none">
|
||||
{{ page.content_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -122,9 +122,9 @@ class TestWebDashboard:
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
def test_network_page(self, web_client: httpx.Client) -> None:
|
||||
"""Test network overview page loads."""
|
||||
response = web_client.get("/network")
|
||||
def test_dashboard_page(self, web_client: httpx.Client) -> None:
|
||||
"""Test dashboard page loads."""
|
||||
response = web_client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Base,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
@@ -264,3 +265,147 @@ def sample_trace_path(api_db_session):
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_member(api_db_session):
|
||||
"""Create a sample member in the database."""
|
||||
member = Member(
|
||||
member_id="alice",
|
||||
name="Alice Smith",
|
||||
callsign="W1ABC",
|
||||
role="Admin",
|
||||
description="Network administrator",
|
||||
contact="alice@example.com",
|
||||
)
|
||||
api_db_session.add(member)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(member)
|
||||
return member
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def receiver_node(api_db_session):
|
||||
"""Create a receiver node in the database."""
|
||||
node = Node(
|
||||
public_key="receiver123receiver123receiver12",
|
||||
name="Receiver Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a message with a receiver node."""
|
||||
message = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
pubkey_prefix="xyz789",
|
||||
text="Channel message with receiver",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_advertisement_with_receiver(api_db_session, sample_node, receiver_node):
|
||||
"""Create an advertisement with source and receiver nodes."""
|
||||
advert = Advertisement(
|
||||
public_key=sample_node.public_key,
|
||||
name="SourceNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
node_id=sample_node.id,
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_telemetry_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a telemetry record with a receiver node."""
|
||||
telemetry = Telemetry(
|
||||
node_public_key="xyz789xyz789xyz789xyz789xyz789xy",
|
||||
parsed_data={"battery_level": 50.0},
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(telemetry)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(telemetry)
|
||||
return telemetry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_trace_path_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a trace path with a receiver node."""
|
||||
trace = TracePath(
|
||||
initiator_tag=99999,
|
||||
path_hashes=["aaa111", "bbb222"],
|
||||
hop_count=2,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(trace)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_with_name_tag(api_db_session):
|
||||
"""Create a node with a name tag for search testing."""
|
||||
node = Node(
|
||||
public_key="searchable123searchable123searc",
|
||||
name="Original Name",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="name",
|
||||
value="Friendly Search Name",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_with_member_tag(api_db_session):
|
||||
"""Create a node with a member_id tag for filter testing."""
|
||||
node = Node(
|
||||
public_key="member123member123member123membe",
|
||||
name="Member Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="member_id",
|
||||
value="alice",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for advertisement API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListAdvertisements:
|
||||
"""Tests for GET /advertisements endpoint."""
|
||||
@@ -55,3 +57,120 @@ class TestGetAdvertisement:
|
||||
"""Test getting a non-existent advertisement."""
|
||||
response = client_no_auth.get("/api/v1/advertisements/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListAdvertisementsFilters:
|
||||
"""Tests for advertisement list query filters."""
|
||||
|
||||
def test_filter_by_search_public_key(self, client_no_auth, sample_advertisement):
|
||||
"""Test filtering advertisements by public key search."""
|
||||
# Partial public key match
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=zzz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_search_name(self, client_no_auth, sample_advertisement):
|
||||
"""Test filtering advertisements by name search."""
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=TestNode")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_advertisement,
|
||||
sample_advertisement_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering advertisements by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_member_id(
|
||||
self, client_no_auth, api_db_session, sample_node_with_member_tag
|
||||
):
|
||||
"""Test filtering advertisements by member_id tag."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
# Create an advertisement for the node with member tag
|
||||
advert = Advertisement(
|
||||
public_key=sample_node_with_member_tag.public_key,
|
||||
name="Member Node Ad",
|
||||
adv_type="CHAT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
node_id=sample_node_with_member_tag.id,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter by member_id
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by since timestamp."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old advertisement
|
||||
old_advert = Advertisement(
|
||||
public_key="old123old123old123old123old123ol",
|
||||
name="Old Advertisement",
|
||||
adv_type="CLIENT",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old advertisement
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/advertisements?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by until timestamp."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old advertisement
|
||||
old_advert = Advertisement(
|
||||
public_key="until123until123until123until12",
|
||||
name="Old Advertisement Until",
|
||||
adv_type="CLIENT",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old advertisement
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/advertisements?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"""Tests for dashboard API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node
|
||||
|
||||
|
||||
class TestDashboardStats:
|
||||
"""Tests for GET /dashboard/stats endpoint."""
|
||||
@@ -63,6 +69,21 @@ class TestDashboardHtml:
|
||||
class TestDashboardActivity:
|
||||
"""Tests for GET /dashboard/activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_advertisement(self, api_db_session):
|
||||
"""Create an advertisement from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
advert = Advertisement(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="TestNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
def test_get_activity_empty(self, client_no_auth):
|
||||
"""Test getting activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
@@ -91,8 +112,12 @@ class TestDashboardActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_activity_with_data(self, client_no_auth, sample_advertisement):
|
||||
"""Test getting activity with advertisement in database."""
|
||||
def test_get_activity_with_data(self, client_no_auth, past_advertisement):
|
||||
"""Test getting activity with advertisement in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -104,6 +129,21 @@ class TestDashboardActivity:
|
||||
class TestMessageActivity:
|
||||
"""Tests for GET /dashboard/message-activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_message(self, api_db_session):
|
||||
"""Create a message from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
message = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="abc123",
|
||||
text="Hello World",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
def test_get_message_activity_empty(self, client_no_auth):
|
||||
"""Test getting message activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
@@ -132,8 +172,12 @@ class TestMessageActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_message_activity_with_data(self, client_no_auth, sample_message):
|
||||
"""Test getting message activity with message in database."""
|
||||
def test_get_message_activity_with_data(self, client_no_auth, past_message):
|
||||
"""Test getting message activity with message in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -145,6 +189,23 @@ class TestMessageActivity:
|
||||
class TestNodeCountHistory:
|
||||
"""Tests for GET /dashboard/node-count endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_node(self, api_db_session):
|
||||
"""Create a node from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
node = Node(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="Test Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=yesterday,
|
||||
last_seen=yesterday,
|
||||
created_at=yesterday,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
def test_get_node_count_empty(self, client_no_auth):
|
||||
"""Test getting node count with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
@@ -173,8 +234,12 @@ class TestNodeCountHistory:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_node_count_with_data(self, client_no_auth, sample_node):
|
||||
"""Test getting node count with node in database."""
|
||||
def test_get_node_count_with_data(self, client_no_auth, past_node):
|
||||
"""Test getting node count with node in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
285
tests/test_api/test_members.py
Normal file
285
tests/test_api/test_members.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Tests for member API routes."""
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
"""Tests for GET /members endpoint."""
|
||||
|
||||
def test_list_members_empty(self, client_no_auth):
|
||||
"""Test listing members when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_members_with_data(self, client_no_auth, sample_member):
|
||||
"""Test listing members with data in database."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["member_id"] == sample_member.member_id
|
||||
assert data["items"][0]["name"] == sample_member.name
|
||||
assert data["items"][0]["callsign"] == sample_member.callsign
|
||||
|
||||
def test_list_members_pagination(self, client_no_auth, sample_member):
|
||||
"""Test member list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/members?limit=25&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 25
|
||||
assert data["offset"] == 10
|
||||
|
||||
def test_list_members_requires_read_auth(self, client_with_auth):
|
||||
"""Test listing members requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get("/api/v1/members")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/members",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetMember:
|
||||
"""Tests for GET /members/{member_id} endpoint."""
|
||||
|
||||
def test_get_member_success(self, client_no_auth, sample_member):
|
||||
"""Test getting a specific member."""
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["name"] == sample_member.name
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
assert data["role"] == sample_member.role
|
||||
assert data["description"] == sample_member.description
|
||||
assert data["contact"] == sample_member.contact
|
||||
|
||||
def test_get_member_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent member."""
|
||||
response = client_no_auth.get("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_get_member_requires_read_auth(self, client_with_auth, sample_member):
|
||||
"""Test getting a member requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestCreateMember:
|
||||
"""Tests for POST /members endpoint."""
|
||||
|
||||
def test_create_member_success(self, client_no_auth):
|
||||
"""Test creating a new member."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "bob",
|
||||
"name": "Bob Jones",
|
||||
"callsign": "W2XYZ",
|
||||
"role": "Member",
|
||||
"description": "Regular member",
|
||||
"contact": "bob@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "bob"
|
||||
assert data["name"] == "Bob Jones"
|
||||
assert data["callsign"] == "W2XYZ"
|
||||
assert data["role"] == "Member"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_member_minimal(self, client_no_auth):
|
||||
"""Test creating a member with only required fields."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "charlie",
|
||||
"name": "Charlie Brown",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "charlie"
|
||||
assert data["name"] == "Charlie Brown"
|
||||
assert data["callsign"] is None
|
||||
assert data["role"] is None
|
||||
|
||||
def test_create_member_duplicate_member_id(self, client_no_auth, sample_member):
|
||||
"""Test creating a member with duplicate member_id fails."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": sample_member.member_id, # "alice" already exists
|
||||
"name": "Another Alice",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_create_member_requires_admin_auth(self, client_with_auth):
|
||||
"""Test creating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestUpdateMember:
|
||||
"""Tests for PUT /members/{member_id} endpoint."""
|
||||
|
||||
def test_update_member_success(self, client_no_auth, sample_member):
|
||||
"""Test updating a member."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={
|
||||
"name": "Alice Johnson",
|
||||
"role": "Super Admin",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Alice Johnson"
|
||||
assert data["role"] == "Super Admin"
|
||||
# Unchanged fields should remain
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
|
||||
def test_update_member_change_member_id(self, client_no_auth, sample_member):
|
||||
"""Test updating member_id."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "alice2"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == "alice2"
|
||||
|
||||
def test_update_member_member_id_collision(
|
||||
self, client_no_auth, api_db_session, sample_member
|
||||
):
|
||||
"""Test updating member_id to one that already exists fails."""
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
# Create another member
|
||||
other_member = Member(
|
||||
member_id="bob",
|
||||
name="Bob",
|
||||
)
|
||||
api_db_session.add(other_member)
|
||||
api_db_session.commit()
|
||||
|
||||
# Try to change alice's member_id to "bob"
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "bob"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_not_found(self, client_no_auth):
|
||||
"""Test updating a non-existent member."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/members/nonexistent-id",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test updating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestDeleteMember:
|
||||
"""Tests for DELETE /members/{member_id} endpoint."""
|
||||
|
||||
def test_delete_member_success(self, client_no_auth, sample_member):
|
||||
"""Test deleting a member."""
|
||||
response = client_no_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's deleted
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_member_not_found(self, client_no_auth):
|
||||
"""Test deleting a non-existent member."""
|
||||
response = client_no_auth.delete("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test deleting a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for message API routes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class TestListMessages:
|
||||
"""Tests for GET /messages endpoint."""
|
||||
@@ -57,3 +59,127 @@ class TestGetMessage:
|
||||
"""Test getting a non-existent message."""
|
||||
response = client_no_auth.get("/api/v1/messages/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListMessagesFilters:
|
||||
"""Tests for message list query filters."""
|
||||
|
||||
def test_filter_by_pubkey_prefix(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by pubkey_prefix."""
|
||||
# Match
|
||||
response = client_no_auth.get("/api/v1/messages?pubkey_prefix=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/messages?pubkey_prefix=xyz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_channel_idx(
|
||||
self, client_no_auth, sample_message, sample_message_with_receiver
|
||||
):
|
||||
"""Test filtering messages by channel_idx."""
|
||||
# Channel 1 should match sample_message_with_receiver
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["channel_idx"] == 1
|
||||
|
||||
# Channel 0 should return no results
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_message,
|
||||
sample_message_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering messages by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/messages?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == sample_message_with_receiver.text
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering messages by since timestamp."""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshcore_hub.common.models import Message
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old message
|
||||
old_msg = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="old123",
|
||||
text="Old message",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_msg)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old message
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/messages?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering messages by until timestamp."""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshcore_hub.common.models import Message
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old message
|
||||
old_msg = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="old456",
|
||||
text="Old message for until",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_msg)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old message
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/messages?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == "Old message for until"
|
||||
|
||||
def test_filter_by_search(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by text search."""
|
||||
# Match
|
||||
response = client_no_auth.get("/api/v1/messages?search=Hello")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# Case insensitive match
|
||||
response = client_no_auth.get("/api/v1/messages?search=hello")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/messages?search=nonexistent")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
@@ -57,6 +57,66 @@ class TestListNodes:
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestListNodesFilters:
|
||||
"""Tests for node list query filters."""
|
||||
|
||||
def test_filter_by_search_public_key(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by public key search."""
|
||||
# Partial public key match
|
||||
response = client_no_auth.get("/api/v1/nodes?search=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?search=zzz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_search_node_name(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by node name search."""
|
||||
response = client_no_auth.get("/api/v1/nodes?search=Test%20Node")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_search_name_tag(self, client_no_auth, sample_node_with_name_tag):
|
||||
"""Test filtering nodes by name tag search."""
|
||||
response = client_no_auth.get("/api/v1/nodes?search=Friendly%20Search")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_adv_type(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by advertisement type."""
|
||||
# Match REPEATER
|
||||
response = client_no_auth.get("/api/v1/nodes?adv_type=REPEATER")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?adv_type=CLIENT")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag):
|
||||
"""Test filtering nodes by member_id tag."""
|
||||
# Match alice
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
@@ -86,6 +146,54 @@ class TestGetNode:
|
||||
response = client_no_auth.get("/api/v1/nodes/nonexistent123")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_node_by_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by public key prefix."""
|
||||
prefix = sample_node.public_key[:8] # First 8 chars
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_by_single_char_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by single character prefix."""
|
||||
prefix = sample_node.public_key[0]
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_prefix_returns_first_alphabetically(
|
||||
self, client_no_auth, api_db_session
|
||||
):
|
||||
"""Test that prefix match returns first node alphabetically."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
# Create two nodes with same prefix but different suffixes
|
||||
# abc... should come before abd...
|
||||
node_a = Node(
|
||||
public_key="abc0000000000000000000000000000000000000000000000000000000000000",
|
||||
name="Node A",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
node_b = Node(
|
||||
public_key="abc1111111111111111111111111111111111111111111111111111111111111",
|
||||
name="Node B",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node_a)
|
||||
api_db_session.add(node_b)
|
||||
api_db_session.commit()
|
||||
|
||||
# Request with prefix should return first alphabetically
|
||||
response = client_no_auth.get("/api/v1/nodes/prefix/abc")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == node_a.public_key
|
||||
|
||||
|
||||
class TestNodeTags:
|
||||
"""Tests for node tag endpoints."""
|
||||
@@ -158,3 +266,166 @@ class TestNodeTags:
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
|
||||
|
||||
class TestMoveNodeTag:
|
||||
"""Tests for PUT /nodes/{public_key}/tags/{key}/move endpoint."""
|
||||
|
||||
# 64-character public key for testing
|
||||
DEST_PUBLIC_KEY = "xyz789xyz789xyz789xyz789xyz789xyabc123abc123abc123abc123abc123ab"
|
||||
|
||||
def test_move_node_tag_success(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test successfully moving a tag to another node."""
|
||||
from meshcore_hub.common.models import Node
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create a second node with 64-char public key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == sample_node_tag.key
|
||||
assert data["value"] == sample_node_tag.value
|
||||
|
||||
# Verify tag is no longer on original node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verify tag is now on new node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{second_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_move_node_tag_source_not_found(self, client_no_auth):
|
||||
"""Test moving a tag from a non-existent node."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/nodes/nonexistent123/tags/somekey/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Source node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_tag_not_found(self, client_no_auth, sample_node):
|
||||
"""Test moving a non-existent tag."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/nonexistent/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Tag not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_dest_not_found(
|
||||
self, client_no_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag to a non-existent destination node."""
|
||||
# 64-character nonexistent public key
|
||||
nonexistent_key = (
|
||||
"1111111111111111111111111111111122222222222222222222222222222222"
|
||||
)
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": nonexistent_key},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Destination node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_conflict(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag when destination already has that key."""
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create second node with same tag key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
# Add the same tag key to second node
|
||||
existing_tag = NodeTag(
|
||||
node_id=second_node.id,
|
||||
key=sample_node_tag.key, # Same key
|
||||
value="different value",
|
||||
)
|
||||
api_db_session.add(existing_tag)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already exists on destination" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_requires_admin(
|
||||
self, client_with_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test that move operation requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_move_node_tag_same_node(self, client_no_auth, api_db_session):
|
||||
"""Test moving a tag to the same node returns 400."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
|
||||
# Create node with 64-char public key
|
||||
full_key = "abc123def456abc123def456abc123deabc123def456abc123def456abc123de"
|
||||
node = Node(
|
||||
public_key=full_key,
|
||||
name="Test Node 64",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
# Create tag
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="test_tag",
|
||||
value="test_value",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{full_key}/tags/test_tag/move",
|
||||
json={"new_public_key": full_key},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "same" in response.json()["detail"].lower()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for telemetry API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListTelemetry:
|
||||
"""Tests for GET /telemetry endpoint."""
|
||||
@@ -51,3 +53,68 @@ class TestGetTelemetry:
|
||||
"""Test getting a non-existent telemetry record."""
|
||||
response = client_no_auth.get("/api/v1/telemetry/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListTelemetryFilters:
|
||||
"""Tests for telemetry list query filters."""
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_telemetry,
|
||||
sample_telemetry_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering telemetry by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/telemetry?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering telemetry by since timestamp."""
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old telemetry
|
||||
old_telemetry = Telemetry(
|
||||
node_public_key="old123old123old123old123old123ol",
|
||||
parsed_data={"battery_level": 10.0},
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_telemetry)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old telemetry
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/telemetry?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering telemetry by until timestamp."""
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old telemetry
|
||||
old_telemetry = Telemetry(
|
||||
node_public_key="until123until123until123until12",
|
||||
parsed_data={"battery_level": 20.0},
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_telemetry)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old telemetry
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/telemetry?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for trace path API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListTracePaths:
|
||||
"""Tests for GET /trace-paths endpoint."""
|
||||
@@ -37,3 +39,70 @@ class TestGetTracePath:
|
||||
"""Test getting a non-existent trace path."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListTracePathsFilters:
|
||||
"""Tests for trace path list query filters."""
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_trace_path,
|
||||
sample_trace_path_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering trace paths by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/trace-paths?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering trace paths by since timestamp."""
|
||||
from meshcore_hub.common.models import TracePath
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old trace path
|
||||
old_trace = TracePath(
|
||||
initiator_tag=11111,
|
||||
path_hashes=["old1", "old2"],
|
||||
hop_count=2,
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_trace)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old trace path
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering trace paths by until timestamp."""
|
||||
from meshcore_hub.common.models import TracePath
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old trace path
|
||||
old_trace = TracePath(
|
||||
initiator_tag=22222,
|
||||
path_hashes=["until1", "until2"],
|
||||
hop_count=2,
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_trace)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old trace path
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
172
tests/test_collector/test_handlers/test_contacts.py
Normal file
172
tests/test_collector/test_handlers/test_contacts.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Tests for contact handler."""
|
||||
|
||||
import pytest
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from meshcore_hub.collector.handlers.contacts import handle_contact
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_manager(db_session):
|
||||
"""Create a mock database manager that uses the test session."""
|
||||
mock_db = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
try:
|
||||
yield db_session
|
||||
db_session.commit()
|
||||
except Exception:
|
||||
db_session.rollback()
|
||||
raise
|
||||
|
||||
mock_db.session_scope = session_scope
|
||||
return mock_db
|
||||
|
||||
|
||||
def test_handle_contact_creates_new_node(db_session, mock_db_manager):
|
||||
"""Test that contact handler creates new node with last_seen=None."""
|
||||
payload = {
|
||||
"public_key": "a" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 1, # chat
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify node was created
|
||||
node = db_session.query(Node).filter_by(public_key="a" * 64).first()
|
||||
assert node is not None
|
||||
assert node.name == "TestNode"
|
||||
assert node.adv_type == "chat"
|
||||
assert node.first_seen is not None
|
||||
assert node.last_seen is None # Should NOT be set by contact sync
|
||||
|
||||
|
||||
def test_handle_contact_updates_existing_node_name(db_session, mock_db_manager):
|
||||
"""Test that contact handler updates name but NOT last_seen."""
|
||||
# Create existing node with a last_seen timestamp
|
||||
last_seen_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
node = Node(
|
||||
public_key="b" * 64,
|
||||
name="OldName",
|
||||
adv_type="chat",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=last_seen_time,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with new name
|
||||
payload = {
|
||||
"public_key": "b" * 64,
|
||||
"adv_name": "NewName",
|
||||
"type": 1,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify name was updated but last_seen was NOT
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="b" * 64).first()
|
||||
assert node.name == "NewName"
|
||||
# Compare timestamps without timezone (SQLite strips timezone info)
|
||||
assert node.last_seen is not None
|
||||
assert node.last_seen.replace(tzinfo=None) == last_seen_time.replace(tzinfo=None)
|
||||
|
||||
|
||||
def test_handle_contact_preserves_existing_adv_type(db_session, mock_db_manager):
|
||||
"""Test that contact handler doesn't overwrite existing adv_type."""
|
||||
# Create existing node with adv_type
|
||||
node = Node(
|
||||
public_key="c" * 64,
|
||||
name="TestNode",
|
||||
adv_type="repeater",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with different type
|
||||
payload = {
|
||||
"public_key": "c" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 1, # chat
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify adv_type was NOT changed
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="c" * 64).first()
|
||||
assert node.adv_type == "repeater" # Should preserve existing
|
||||
|
||||
|
||||
def test_handle_contact_sets_adv_type_if_missing(db_session, mock_db_manager):
|
||||
"""Test that contact handler sets adv_type if node doesn't have one."""
|
||||
# Create existing node without adv_type
|
||||
node = Node(
|
||||
public_key="d" * 64,
|
||||
name="TestNode",
|
||||
adv_type=None,
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=None,
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Process contact with type
|
||||
payload = {
|
||||
"public_key": "d" * 64,
|
||||
"adv_name": "TestNode",
|
||||
"type": 2, # repeater
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify adv_type was set
|
||||
db_session.expire_all()
|
||||
node = db_session.query(Node).filter_by(public_key="d" * 64).first()
|
||||
assert node.adv_type == "repeater"
|
||||
|
||||
|
||||
def test_handle_contact_ignores_missing_public_key(db_session, mock_db_manager, caplog):
|
||||
"""Test that contact handler handles missing public_key gracefully."""
|
||||
payload = {
|
||||
"adv_name": "TestNode",
|
||||
"type": 1,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
# Verify warning was logged and no node created
|
||||
assert "missing public_key" in caplog.text
|
||||
count = db_session.query(Node).count()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_handle_contact_node_type_mapping(db_session, mock_db_manager):
|
||||
"""Test that numeric node types are correctly mapped to strings."""
|
||||
test_cases = [
|
||||
(0, "none"),
|
||||
(1, "chat"),
|
||||
(2, "repeater"),
|
||||
(3, "room"),
|
||||
]
|
||||
|
||||
for numeric_type, expected_string in test_cases:
|
||||
public_key = str(numeric_type) * 64
|
||||
payload = {
|
||||
"public_key": public_key,
|
||||
"adv_name": f"Node{numeric_type}",
|
||||
"type": numeric_type,
|
||||
}
|
||||
|
||||
handle_contact("receiver123", "contact", payload, mock_db_manager)
|
||||
|
||||
node = db_session.query(Node).filter_by(public_key=public_key).first()
|
||||
assert node.adv_type == expected_string
|
||||
@@ -99,7 +99,7 @@ class TestLoadTagsFile:
|
||||
"""Test loading file with full format (value and type)."""
|
||||
data = {
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
|
||||
"location": {"value": "52.0,1.0", "type": "coordinate"},
|
||||
"is_active": {"value": "true", "type": "boolean"},
|
||||
"altitude": {"value": "150", "type": "number"},
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class TestLoadTagsFile:
|
||||
|
||||
result = load_tags_file(f.name)
|
||||
key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
assert result[key]["location"]["type"] == "coordinate"
|
||||
assert result[key]["is_active"]["type"] == "boolean"
|
||||
assert result[key]["altitude"]["type"] == "number"
|
||||
|
||||
Path(f.name).unlink()
|
||||
@@ -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()
|
||||
|
||||
@@ -6,26 +6,12 @@ from meshcore_hub.common.config import (
|
||||
CollectorSettings,
|
||||
APISettings,
|
||||
WebSettings,
|
||||
LogLevel,
|
||||
InterfaceMode,
|
||||
)
|
||||
|
||||
|
||||
class TestCommonSettings:
|
||||
"""Tests for CommonSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CommonSettings(_env_file=None)
|
||||
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.log_level == LogLevel.INFO
|
||||
assert settings.mqtt_host == "localhost"
|
||||
assert settings.mqtt_port == 1883
|
||||
assert settings.mqtt_username is None
|
||||
assert settings.mqtt_password is None
|
||||
assert settings.mqtt_prefix == "meshcore"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test custom DATA_HOME setting."""
|
||||
settings = CommonSettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -36,37 +22,19 @@ class TestCommonSettings:
|
||||
class TestInterfaceSettings:
|
||||
"""Tests for InterfaceSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = InterfaceSettings(_env_file=None)
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom setting values."""
|
||||
settings = InterfaceSettings(
|
||||
_env_file=None, serial_port="/dev/ttyACM0", serial_baud=9600
|
||||
)
|
||||
|
||||
assert settings.interface_mode == InterfaceMode.RECEIVER
|
||||
assert settings.serial_port == "/dev/ttyUSB0"
|
||||
assert settings.serial_baud == 115200
|
||||
assert settings.mock_device is False
|
||||
assert settings.serial_port == "/dev/ttyACM0"
|
||||
assert settings.serial_baud == 9600
|
||||
|
||||
|
||||
class TestCollectorSettings:
|
||||
"""Tests for CollectorSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CollectorSettings(_env_file=None)
|
||||
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.collector_data_dir == "data/collector"
|
||||
|
||||
# seed_home defaults to ./seed (normalized to "seed")
|
||||
assert settings.seed_home == "./seed"
|
||||
assert settings.effective_seed_home == "seed"
|
||||
# node_tags_file and members_file are derived from effective_seed_home
|
||||
assert settings.node_tags_file == "seed/node_tags.yaml"
|
||||
assert settings.members_file == "seed/members.yaml"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = CollectorSettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -76,10 +44,6 @@ class TestCollectorSettings:
|
||||
== "sqlite:////custom/data/collector/meshcore.db"
|
||||
)
|
||||
assert settings.collector_data_dir == "/custom/data/collector"
|
||||
# seed_home is independent of data_home
|
||||
assert settings.effective_seed_home == "seed"
|
||||
assert settings.node_tags_file == "seed/node_tags.yaml"
|
||||
assert settings.members_file == "seed/members.yaml"
|
||||
|
||||
def test_explicit_database_url_overrides(self) -> None:
|
||||
"""Test that explicit database_url overrides the default."""
|
||||
@@ -103,19 +67,6 @@ class TestCollectorSettings:
|
||||
class TestAPISettings:
|
||||
"""Tests for APISettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = APISettings(_env_file=None)
|
||||
|
||||
assert settings.api_host == "0.0.0.0"
|
||||
assert settings.api_port == 8000
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.api_read_key is None
|
||||
assert settings.api_admin_key is None
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective database path."""
|
||||
settings = APISettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -136,17 +87,6 @@ class TestAPISettings:
|
||||
class TestWebSettings:
|
||||
"""Tests for WebSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = WebSettings(_env_file=None)
|
||||
|
||||
assert settings.web_host == "0.0.0.0"
|
||||
assert settings.web_port == 8080
|
||||
assert settings.api_base_url == "http://localhost:8000"
|
||||
assert settings.network_name == "MeshCore Network"
|
||||
# Path normalizes ./data to data
|
||||
assert settings.web_data_dir == "data/web"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = WebSettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
@@ -55,14 +55,14 @@ class TestNodeModel:
|
||||
def test_node_tags_relationship(self, db_session) -> None:
|
||||
"""Test node-tag relationship."""
|
||||
node = Node(public_key="b" * 64, name="Tagged Node")
|
||||
tag = NodeTag(key="location", value="51.5,-0.1", value_type="coordinate")
|
||||
tag = NodeTag(key="altitude", value="150", value_type="number")
|
||||
node.tags.append(tag)
|
||||
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
assert len(node.tags) == 1
|
||||
assert node.tags[0].key == "location"
|
||||
assert node.tags[0].key == "altitude"
|
||||
|
||||
|
||||
class TestMessageModel:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -40,7 +40,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
@@ -48,7 +48,7 @@ class MockHttpClient:
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456abc123def456abc123def456ab",
|
||||
"public_key": "def456abc123def456abc123def456abc123def456abc123def456abc123def4",
|
||||
"name": "Node Two",
|
||||
"adv_type": "CLIENT",
|
||||
"last_seen": "2024-01-01T11:00:00Z",
|
||||
@@ -62,12 +62,14 @@ class MockHttpClient:
|
||||
},
|
||||
}
|
||||
|
||||
# Default single node response
|
||||
self._responses["GET:/api/v1/nodes/abc123def456abc123def456abc123de"] = {
|
||||
# Default single node response (exact match)
|
||||
self._responses[
|
||||
"GET:/api/v1/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
] = {
|
||||
"status_code": 200,
|
||||
"json": {
|
||||
"id": "node-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
@@ -110,7 +112,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "adv-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"received_at": "2024-01-01T12:00:00Z",
|
||||
@@ -127,7 +129,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "tel-1",
|
||||
"node_public_key": "abc123def456abc123def456abc123de",
|
||||
"node_public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"parsed_data": {"battery_level": 85.5},
|
||||
"received_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
@@ -255,6 +257,18 @@ class MockHttpClient:
|
||||
key = f"POST:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def put(
|
||||
self, path: str, json: dict | None = None, params: dict | None = None
|
||||
) -> Response:
|
||||
"""Mock PUT request."""
|
||||
key = f"PUT:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def delete(self, path: str, params: dict | None = None) -> Response:
|
||||
"""Mock DELETE request."""
|
||||
key = f"DELETE:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Mock close method."""
|
||||
pass
|
||||
@@ -302,6 +316,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 +325,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 +351,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
|
||||
|
||||
|
||||
|
||||
426
tests/test_web/test_admin.py
Normal file
426
tests/test_web/test_admin.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""Tests for admin web routes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
from .conftest import MockHttpClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client_admin() -> MockHttpClient:
|
||||
"""Create a mock HTTP client for admin tests."""
|
||||
client = MockHttpClient()
|
||||
|
||||
# Mock the nodes API response for admin dropdown
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
"name": "Node Two",
|
||||
"adv_type": "CHAT",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T11:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock node tags response
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
200,
|
||||
[
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "location",
|
||||
"value": "building-a",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Mock create tag response
|
||||
client.set_response(
|
||||
"POST",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
201,
|
||||
{
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock update tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock move tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment/move",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock delete tag response
|
||||
client.set_response(
|
||||
"DELETE",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
204,
|
||||
None,
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin enabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
network_city="Test City",
|
||||
network_country="Test Country",
|
||||
network_radio_config="Test Radio Config",
|
||||
network_contact_email="test@example.com",
|
||||
admin_enabled=True,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin disabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
network_city="Test City",
|
||||
network_country="Test Country",
|
||||
network_radio_config="Test Radio Config",
|
||||
network_contact_email="test@example.com",
|
||||
admin_enabled=False,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers() -> dict:
|
||||
"""Authentication headers for admin requests."""
|
||||
return {
|
||||
"X-Forwarded-User": "test-user-id",
|
||||
"X-Forwarded-Email": "test@example.com",
|
||||
"X-Forwarded-Preferred-Username": "testuser",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(admin_app: Any, mock_http_client_admin: MockHttpClient) -> TestClient:
|
||||
"""Create a test client with admin enabled."""
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
return TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_disabled(
|
||||
admin_app_disabled: Any, mock_http_client_admin: MockHttpClient
|
||||
) -> TestClient:
|
||||
"""Create a test client with admin disabled."""
|
||||
admin_app_disabled.state.http_client = mock_http_client_admin
|
||||
return TestClient(admin_app_disabled, raise_server_exceptions=True)
|
||||
|
||||
|
||||
class TestAdminHome:
|
||||
"""Tests for admin home page."""
|
||||
|
||||
def test_admin_home_enabled(self, admin_client, auth_headers):
|
||||
"""Test admin home page when enabled."""
|
||||
response = admin_client.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Admin" in response.text
|
||||
assert "Node Tags" in response.text
|
||||
|
||||
def test_admin_home_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test admin home page when disabled."""
|
||||
response = admin_client_disabled.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_admin_home_unauthenticated(self, admin_client):
|
||||
"""Test admin home page without authentication."""
|
||||
response = admin_client.get("/a/")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
|
||||
|
||||
class TestAdminNodeTags:
|
||||
"""Tests for admin node tags page."""
|
||||
|
||||
def test_node_tags_page_no_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page without selecting a node."""
|
||||
response = admin_client.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
assert "Select a Node" in response.text
|
||||
# Should show node dropdown
|
||||
assert "Node One" in response.text
|
||||
assert "Node Two" in response.text
|
||||
|
||||
def test_node_tags_page_with_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page with a node selected."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
# Should show the selected node's tags
|
||||
assert "environment" in response.text
|
||||
assert "production" in response.text
|
||||
assert "location" in response.text
|
||||
assert "building-a" in response.text
|
||||
|
||||
def test_node_tags_page_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test node tags page when admin is disabled."""
|
||||
response = admin_client_disabled.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_node_tags_page_with_message(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays success message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&message=Tag%20created%20successfully",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag created successfully" in response.text
|
||||
|
||||
def test_node_tags_page_with_error(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays error message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&error=Tag%20already%20exists",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag already exists" in response.text
|
||||
|
||||
def test_node_tags_page_unauthenticated(self, admin_client):
|
||||
"""Test node tags page without authentication."""
|
||||
response = admin_client.get("/a/node-tags")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
|
||||
|
||||
class TestAdminCreateTag:
|
||||
"""Tests for creating node tags."""
|
||||
|
||||
def test_create_tag_success(self, admin_client, auth_headers):
|
||||
"""Test creating a new tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "created" in response.headers["location"]
|
||||
|
||||
def test_create_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test creating tag when admin is disabled."""
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_create_tag_unauthenticated(self, admin_client):
|
||||
"""Test creating tag without authentication."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestAdminUpdateTag:
|
||||
"""Tests for updating node tags."""
|
||||
|
||||
def test_update_tag_success(self, admin_client, auth_headers):
|
||||
"""Test updating a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "updated" in response.headers["location"]
|
||||
|
||||
def test_update_tag_not_found(
|
||||
self, admin_app, mock_http_client_admin: MockHttpClient, auth_headers
|
||||
):
|
||||
"""Test updating a non-existent tag returns error."""
|
||||
# Set up 404 response for this specific tag
|
||||
mock_http_client_admin.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/nonexistent",
|
||||
404,
|
||||
{"detail": "Tag not found"},
|
||||
)
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
client = TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
response = client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "nonexistent",
|
||||
"value": "value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "error=" in response.headers["location"]
|
||||
assert "not+found" in response.headers["location"].lower()
|
||||
|
||||
def test_update_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test updating tag when admin is disabled."""
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAdminMoveTag:
|
||||
"""Tests for moving node tags."""
|
||||
|
||||
def test_move_tag_success(self, admin_client, auth_headers):
|
||||
"""Test moving a tag to another node."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/move",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"new_public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
# Should redirect to destination node
|
||||
assert "xyz789xyz789xyz789xyz789xyz789xy" in response.headers["location"]
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "moved" in response.headers["location"]
|
||||
|
||||
|
||||
class TestAdminDeleteTag:
|
||||
"""Tests for deleting node tags."""
|
||||
|
||||
def test_delete_tag_success(self, admin_client, auth_headers):
|
||||
"""Test deleting a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/delete",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "deleted" in response.headers["location"]
|
||||
364
tests/test_web/test_advertisements.py
Normal file
364
tests/test_web/test_advertisements.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""Tests for the advertisements page route."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestAdvertisementsPage:
|
||||
"""Tests for the advertisements page."""
|
||||
|
||||
def test_advertisements_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page returns 200 status code."""
|
||||
response = client.get("/advertisements")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page returns HTML content."""
|
||||
response = client.get("/advertisements")
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_advertisements_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page contains the network name."""
|
||||
response = client.get("/advertisements")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_advertisements_displays_advertisement_list(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisements from API."""
|
||||
response = client.get("/advertisements")
|
||||
assert response.status_code == 200
|
||||
# Check for advertisement data from mock
|
||||
assert "Node One" in response.text
|
||||
|
||||
def test_advertisements_displays_adv_type(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisement types."""
|
||||
response = client.get("/advertisements")
|
||||
# Should show adv type from mock data
|
||||
assert "REPEATER" in response.text
|
||||
|
||||
|
||||
class TestAdvertisementsPageFilters:
|
||||
"""Tests for advertisements page filtering."""
|
||||
|
||||
def test_advertisements_with_search(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with search parameter."""
|
||||
response = client.get("/advertisements?search=node")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with member_id filter."""
|
||||
response = client.get("/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with public_key filter."""
|
||||
response = client.get(
|
||||
"/advertisements?public_key=abc123def456abc123def456abc123de"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_pagination(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with pagination parameters."""
|
||||
response = client.get("/advertisements?page=1&limit=25")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_page_2(self, client: TestClient) -> None:
|
||||
"""Test advertisements page 2."""
|
||||
response = client.get("/advertisements?page=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with multiple filters."""
|
||||
response = client.get(
|
||||
"/advertisements?search=test&member_id=alice&page=1&limit=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageDropdowns:
|
||||
"""Tests for advertisements page dropdown data."""
|
||||
|
||||
def test_advertisements_loads_members_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads members for filter dropdown."""
|
||||
# Set up members response
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/members",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{"id": "m1", "member_id": "alice", "name": "Alice"},
|
||||
{"id": "m2", "member_id": "bob", "name": "Bob"},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Members should be available for dropdown
|
||||
assert "Alice" in response.text or "alice" in response.text
|
||||
|
||||
def test_advertisements_loads_nodes_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads nodes for filter dropdown."""
|
||||
# Set up nodes response with tags
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Node Alpha",
|
||||
"tags": [{"key": "name", "value": "Custom Name"}],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Node Beta",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsNodeSorting:
|
||||
"""Tests for node sorting in advertisements dropdown."""
|
||||
|
||||
def test_nodes_sorted_by_display_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes are sorted alphabetically by display name."""
|
||||
# Set up nodes with tags - "Zebra" should come after "Alpha"
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra Node",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Alpha Node",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Both nodes should appear
|
||||
text = response.text
|
||||
assert "Alpha Node" in text or "alpha" in text.lower()
|
||||
assert "Zebra Node" in text or "zebra" in text.lower()
|
||||
|
||||
def test_nodes_sorted_by_tag_name_when_present(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes use tag name for sorting when available."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra",
|
||||
"tags": [{"key": "name", "value": "Alpha Custom"}],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_fallback_to_public_key_when_no_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes fall back to public_key when no name."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123def456",
|
||||
"name": None,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageAPIErrors:
|
||||
"""Tests for advertisements page handling API errors."""
|
||||
|
||||
def test_advertisements_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API errors gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/advertisements", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
status_code=404,
|
||||
json_data={"detail": "Not found"},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_members_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles members API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/members", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without member dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_nodes_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles nodes API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without node dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_empty_response(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles empty advertisements list."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPagination:
|
||||
"""Tests for advertisements pagination calculations."""
|
||||
|
||||
def test_pagination_calculates_total_pages(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that pagination correctly calculates total pages."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 150},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
# With limit=50 and total=150, should have 3 pages
|
||||
response = client.get("/advertisements?limit=50")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_pagination_with_zero_total(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test pagination with zero results shows at least 1 page."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the network overview page route."""
|
||||
"""Tests for the dashboard page route."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -7,29 +7,29 @@ from fastapi.testclient import TestClient
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestNetworkPage:
|
||||
"""Tests for the network overview page."""
|
||||
class TestDashboardPage:
|
||||
"""Tests for the dashboard page."""
|
||||
|
||||
def test_network_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that network page returns 200 status code."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page returns 200 status code."""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_network_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that network page returns HTML content."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page returns HTML content."""
|
||||
response = client.get("/dashboard")
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_network_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that network page contains the network name."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page contains the network name."""
|
||||
response = client.get("/dashboard")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_network_displays_stats(
|
||||
def test_dashboard_displays_stats(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page displays statistics."""
|
||||
response = client.get("/network")
|
||||
"""Test that dashboard page displays statistics."""
|
||||
response = client.get("/dashboard")
|
||||
# Check for stats from mock response
|
||||
assert response.status_code == 200
|
||||
# The mock returns total_nodes: 10, active_nodes: 5, etc.
|
||||
@@ -37,24 +37,24 @@ class TestNetworkPage:
|
||||
assert "10" in response.text # total_nodes
|
||||
assert "5" in response.text # active_nodes
|
||||
|
||||
def test_network_displays_message_counts(
|
||||
def test_dashboard_displays_message_counts(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page displays message counts."""
|
||||
response = client.get("/network")
|
||||
"""Test that dashboard page displays message counts."""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
# Mock returns total_messages: 100, messages_today: 15
|
||||
assert "100" in response.text
|
||||
assert "15" in response.text
|
||||
|
||||
|
||||
class TestNetworkPageAPIErrors:
|
||||
"""Tests for network page handling API errors."""
|
||||
class TestDashboardPageAPIErrors:
|
||||
"""Tests for dashboard page handling API errors."""
|
||||
|
||||
def test_network_handles_api_error(
|
||||
def test_dashboard_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page handles API errors gracefully."""
|
||||
"""Test that dashboard page handles API errors gracefully."""
|
||||
# Set error response for stats endpoint
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/dashboard/stats", status_code=500, json_data=None
|
||||
@@ -62,15 +62,15 @@ class TestNetworkPageAPIErrors:
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/network")
|
||||
response = client.get("/dashboard")
|
||||
|
||||
# Should still return 200 (page renders with defaults)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_network_handles_api_not_found(
|
||||
def test_dashboard_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page handles API 404 gracefully."""
|
||||
"""Test that dashboard page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/dashboard/stats",
|
||||
@@ -80,7 +80,7 @@ class TestNetworkPageAPIErrors:
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/network")
|
||||
response = client.get("/dashboard")
|
||||
|
||||
# Should still return 200 (page renders with defaults)
|
||||
assert response.status_code == 200
|
||||
87
tests/test_web/test_health.py
Normal file
87
tests/test_web/test_health.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for the health check endpoints."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests for the /health endpoint."""
|
||||
|
||||
def test_health_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns 200 status code."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_returns_json(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns JSON content."""
|
||||
response = client.get("/health")
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
def test_health_returns_healthy_status(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns healthy status."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_health_returns_version(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns version."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["version"] == __version__
|
||||
|
||||
|
||||
class TestHealthReadyEndpoint:
|
||||
"""Tests for the /health/ready endpoint."""
|
||||
|
||||
def test_health_ready_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health/ready endpoint returns 200 status code."""
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_ready_returns_json(self, client: TestClient) -> None:
|
||||
"""Test that health/ready endpoint returns JSON content."""
|
||||
response = client.get("/health/ready")
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
def test_health_ready_returns_ready_status(self, client: TestClient) -> None:
|
||||
"""Test that health/ready returns ready status when API is connected."""
|
||||
response = client.get("/health/ready")
|
||||
data = response.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["api"] == "connected"
|
||||
|
||||
def test_health_ready_with_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that health/ready handles API errors gracefully."""
|
||||
mock_http_client.set_response("GET", "/health", status_code=500, json_data=None)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "not_ready"
|
||||
assert "status 500" in data["api"]
|
||||
|
||||
def test_health_ready_with_api_404(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that health/ready handles API 404 response."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/health", status_code=404, json_data={"detail": "Not found"}
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "not_ready"
|
||||
assert "status 404" in data["api"]
|
||||
@@ -73,32 +73,44 @@ class TestNodeDetailPage:
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page returns 200 status code."""
|
||||
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_node_detail_returns_html(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page returns HTML content."""
|
||||
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_node_detail_displays_node_info(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page displays node information."""
|
||||
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
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
|
||||
) -> None:
|
||||
"""Test that node detail page displays the full public key."""
|
||||
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||
assert "abc123def456abc123def456abc123de" in response.text
|
||||
response = client.get(
|
||||
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
)
|
||||
assert (
|
||||
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
class TestNodesPageAPIErrors:
|
||||
@@ -122,7 +134,7 @@ class TestNodesPageAPIErrors:
|
||||
def test_node_detail_handles_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that node detail page handles 404 from API."""
|
||||
"""Test that node detail page returns 404 when node not found."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/nonexistent",
|
||||
@@ -131,8 +143,9 @@ class TestNodesPageAPIErrors:
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
client = TestClient(web_app, raise_server_exceptions=False)
|
||||
response = client.get("/nodes/nonexistent")
|
||||
|
||||
# Should still return 200 (page renders but shows no node)
|
||||
assert response.status_code == 200
|
||||
# Should return 404 with custom error page
|
||||
assert response.status_code == 404
|
||||
assert "Page Not Found" in response.text
|
||||
|
||||
488
tests/test_web/test_pages.py
Normal file
488
tests/test_web/test_pages.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Tests for custom pages functionality."""
|
||||
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub.web.pages import CustomPage, PageLoader
|
||||
|
||||
|
||||
class TestCustomPage:
|
||||
"""Tests for the CustomPage dataclass."""
|
||||
|
||||
def test_url_property(self) -> None:
|
||||
"""Test that url property returns correct path."""
|
||||
page = CustomPage(
|
||||
slug="about",
|
||||
title="About Us",
|
||||
menu_order=10,
|
||||
content_html="<p>Content</p>",
|
||||
file_path="/pages/about.md",
|
||||
)
|
||||
assert page.url == "/pages/about"
|
||||
|
||||
def test_url_property_with_hyphenated_slug(self) -> None:
|
||||
"""Test url property with hyphenated slug."""
|
||||
page = CustomPage(
|
||||
slug="terms-of-service",
|
||||
title="Terms of Service",
|
||||
menu_order=50,
|
||||
content_html="<p>Terms</p>",
|
||||
file_path="/pages/terms-of-service.md",
|
||||
)
|
||||
assert page.url == "/pages/terms-of-service"
|
||||
|
||||
|
||||
class TestPageLoader:
|
||||
"""Tests for the PageLoader class."""
|
||||
|
||||
def test_load_pages_nonexistent_directory(self) -> None:
|
||||
"""Test loading from a non-existent directory."""
|
||||
loader = PageLoader("/nonexistent/path")
|
||||
loader.load_pages()
|
||||
|
||||
assert loader.get_menu_pages() == []
|
||||
|
||||
def test_load_pages_empty_directory(self) -> None:
|
||||
"""Test loading from an empty directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
assert loader.get_menu_pages() == []
|
||||
|
||||
def test_load_pages_with_frontmatter(self) -> None:
|
||||
"""Test loading a page with full frontmatter."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
page_path = Path(tmpdir) / "about.md"
|
||||
page_path.write_text(
|
||||
"""---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# About
|
||||
|
||||
This is the about page.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].slug == "about"
|
||||
assert pages[0].title == "About Us"
|
||||
assert pages[0].menu_order == 10
|
||||
assert "About</h1>" in pages[0].content_html
|
||||
assert "<p>This is the about page.</p>" in pages[0].content_html
|
||||
|
||||
def test_load_pages_default_slug_from_filename(self) -> None:
|
||||
"""Test that slug defaults to filename when not specified."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
page_path = Path(tmpdir) / "my-custom-page.md"
|
||||
page_path.write_text(
|
||||
"""---
|
||||
title: My Custom Page
|
||||
---
|
||||
|
||||
Content here.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].slug == "my-custom-page"
|
||||
|
||||
def test_load_pages_default_title_from_slug(self) -> None:
|
||||
"""Test that title defaults to titlecased slug when not specified."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
page_path = Path(tmpdir) / "terms-of-service.md"
|
||||
page_path.write_text("Just content, no frontmatter.")
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].title == "Terms Of Service"
|
||||
|
||||
def test_load_pages_default_menu_order(self) -> None:
|
||||
"""Test that menu_order defaults to 100."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
page_path = Path(tmpdir) / "page.md"
|
||||
page_path.write_text(
|
||||
"""---
|
||||
title: Test Page
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].menu_order == 100
|
||||
|
||||
def test_load_pages_sorted_by_menu_order(self) -> None:
|
||||
"""Test that pages are sorted by menu_order then title."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create pages with different menu_order values
|
||||
(Path(tmpdir) / "page-z.md").write_text(
|
||||
"""---
|
||||
title: Z Page
|
||||
menu_order: 30
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "page-a.md").write_text(
|
||||
"""---
|
||||
title: A Page
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "page-m.md").write_text(
|
||||
"""---
|
||||
title: M Page
|
||||
menu_order: 20
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 3
|
||||
assert [p.title for p in pages] == ["A Page", "M Page", "Z Page"]
|
||||
|
||||
def test_load_pages_secondary_sort_by_title(self) -> None:
|
||||
"""Test that pages with same menu_order are sorted by title."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "zebra.md").write_text(
|
||||
"""---
|
||||
title: Zebra
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "apple.md").write_text(
|
||||
"""---
|
||||
title: Apple
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 2
|
||||
assert [p.title for p in pages] == ["Apple", "Zebra"]
|
||||
|
||||
def test_get_page_returns_correct_page(self) -> None:
|
||||
"""Test that get_page returns the page with the given slug."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
"""---
|
||||
title: About
|
||||
slug: about
|
||||
---
|
||||
|
||||
About content.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "contact.md").write_text(
|
||||
"""---
|
||||
title: Contact
|
||||
slug: contact
|
||||
---
|
||||
|
||||
Contact content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
page = loader.get_page("about")
|
||||
assert page is not None
|
||||
assert page.slug == "about"
|
||||
assert page.title == "About"
|
||||
|
||||
page = loader.get_page("contact")
|
||||
assert page is not None
|
||||
assert page.slug == "contact"
|
||||
|
||||
def test_get_page_returns_none_for_unknown_slug(self) -> None:
|
||||
"""Test that get_page returns None for unknown slugs."""
|
||||
loader = PageLoader("/nonexistent")
|
||||
loader.load_pages()
|
||||
|
||||
assert loader.get_page("unknown") is None
|
||||
|
||||
def test_reload_clears_and_reloads(self) -> None:
|
||||
"""Test that reload() clears existing pages and reloads from disk."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
page_path = Path(tmpdir) / "page.md"
|
||||
page_path.write_text(
|
||||
"""---
|
||||
title: Original
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].title == "Original"
|
||||
|
||||
# Update the file
|
||||
page_path.write_text(
|
||||
"""---
|
||||
title: Updated
|
||||
---
|
||||
|
||||
New content.
|
||||
"""
|
||||
)
|
||||
|
||||
loader.reload()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].title == "Updated"
|
||||
|
||||
def test_load_pages_ignores_non_md_files(self) -> None:
|
||||
"""Test that only .md files are loaded."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "page.md").write_text("# Valid Page")
|
||||
(Path(tmpdir) / "readme.txt").write_text("Not a markdown file")
|
||||
(Path(tmpdir) / "data.json").write_text('{"key": "value"}')
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert pages[0].slug == "page"
|
||||
|
||||
def test_markdown_tables_rendered(self) -> None:
|
||||
"""Test that markdown tables are rendered to HTML."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "tables.md").write_text(
|
||||
"""---
|
||||
title: Tables
|
||||
---
|
||||
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert "<table>" in pages[0].content_html
|
||||
assert "<th>" in pages[0].content_html
|
||||
|
||||
def test_markdown_fenced_code_rendered(self) -> None:
|
||||
"""Test that fenced code blocks are rendered."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "code.md").write_text(
|
||||
"""---
|
||||
title: Code
|
||||
---
|
||||
|
||||
```python
|
||||
def hello():
|
||||
print("Hello!")
|
||||
```
|
||||
"""
|
||||
)
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert "<pre>" in pages[0].content_html
|
||||
assert "def hello():" in pages[0].content_html
|
||||
|
||||
|
||||
class TestPagesRoute:
|
||||
"""Tests for the custom pages route."""
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
"""---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# About Our Network
|
||||
|
||||
Welcome to the network.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "faq.md").write_text(
|
||||
"""---
|
||||
title: FAQ
|
||||
slug: faq
|
||||
menu_order: 20
|
||||
---
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
Here are some answers.
|
||||
"""
|
||||
)
|
||||
yield tmpdir
|
||||
|
||||
@pytest.fixture
|
||||
def web_app_with_pages(
|
||||
self, pages_dir: str, mock_http_client: Any
|
||||
) -> Generator[Any, None, None]:
|
||||
"""Create a web app with custom pages configured."""
|
||||
import os
|
||||
|
||||
# Temporarily set PAGES_HOME environment variable
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
)
|
||||
app.state.http_client = mock_http_client
|
||||
|
||||
yield app
|
||||
|
||||
# Cleanup
|
||||
del os.environ["PAGES_HOME"]
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_pages(
|
||||
self, web_app_with_pages: Any, mock_http_client: Any
|
||||
) -> TestClient:
|
||||
"""Create a test client with custom pages."""
|
||||
web_app_with_pages.state.http_client = mock_http_client
|
||||
return TestClient(web_app_with_pages, raise_server_exceptions=True)
|
||||
|
||||
def test_get_page_success(self, client_with_pages: TestClient) -> None:
|
||||
"""Test successfully retrieving a custom page."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
assert response.status_code == 200
|
||||
assert "About Us" in response.text
|
||||
assert "About Our Network" in response.text
|
||||
assert "Welcome to the network" in response.text
|
||||
|
||||
def test_get_page_not_found(self, client_with_pages: TestClient) -> None:
|
||||
"""Test 404 for unknown page slug."""
|
||||
response = client_with_pages.get("/pages/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages appear in navigation."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
assert response.status_code == 200
|
||||
# Check for navigation links
|
||||
assert 'href="/pages/about"' in response.text
|
||||
assert 'href="/pages/faq"' in response.text
|
||||
|
||||
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that pages are sorted by menu_order in navigation."""
|
||||
response = client_with_pages.get("/pages/about")
|
||||
assert response.status_code == 200
|
||||
# About (order 10) should appear before FAQ (order 20)
|
||||
about_pos = response.text.find('href="/pages/about"')
|
||||
faq_pos = response.text.find('href="/pages/faq"')
|
||||
assert about_pos < faq_pos
|
||||
|
||||
|
||||
class TestPagesInSitemap:
|
||||
"""Tests for custom pages in sitemap."""
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
"""---
|
||||
title: About
|
||||
slug: about
|
||||
---
|
||||
|
||||
About page.
|
||||
"""
|
||||
)
|
||||
yield tmpdir
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_pages_for_sitemap(
|
||||
self, pages_dir: str, mock_http_client: Any
|
||||
) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client with custom pages for sitemap testing."""
|
||||
import os
|
||||
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
)
|
||||
app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
del os.environ["PAGES_HOME"]
|
||||
|
||||
def test_pages_included_in_sitemap(
|
||||
self, client_with_pages_for_sitemap: TestClient
|
||||
) -> None:
|
||||
"""Test that custom pages are included in sitemap.xml."""
|
||||
response = client_with_pages_for_sitemap.get("/sitemap.xml")
|
||||
assert response.status_code == 200
|
||||
assert "/pages/about" in response.text
|
||||
assert "<changefreq>weekly</changefreq>" in response.text
|
||||
Reference in New Issue
Block a user