Compare commits

46 Commits

Author SHA1 Message Date
Louis King
deaab9b9de Rename /network to /dashboard and add reusable icon macros
- Renamed network route, template, and tests to dashboard
- Added logo.svg for favicon and navbar branding
- Created reusable Jinja2 icon macros for navigation and UI elements
- Updated home page hero layout with centered content and larger logo
- Added Map button alongside Dashboard button in hero section
- Navigation menu items now display icons before labels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:53:36 +00:00
Louis King
95636ef580 Removed Claude Code workflow 2026-02-06 19:19:10 +00:00
JingleManSweep
5831592f88 Merge pull request #79 from ipnet-mesh/feat/custom-pages
Feat/custom pages
2026-02-06 19:14:53 +00:00
Louis King
bc7bff8b82 Updates 2026-02-06 19:14:19 +00:00
Louis King
9445d2150c Fix links and update join guide
- Fix T114 manufacturer (Heltec, not LilyGO) and link
- Fix T1000-E product link
- Fix Google Play and App Store links
- Add Amazon to where to buy options
- Add radio configuration step

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:10:00 +00:00
Louis King
3e9f478a65 Replace example about page with join guide
Add getting started guide covering:
- Node types (Companion, Repeater, Room Server)
- Frequency regulations (868MHz EU/UK, 915MHz US/AU)
- Recommended hardware (Heltec V3, T114, T1000-E, T-Deck Plus)
- Mobile apps (Android/iOS)
- Links to MeshCore docs and web flasher

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:04:56 +00:00
JingleManSweep
6656bd8214 Merge pull request #78 from ipnet-mesh/feat/custom-pages
Add custom markdown pages feature to web dashboard
2026-02-06 18:40:42 +00:00
Louis King
0f50bf4a41 Add custom markdown pages feature to web dashboard
Allows adding static content pages (About, FAQ, etc.) as markdown files
with YAML frontmatter. Pages are stored in PAGES_HOME directory (default:
./pages), automatically appear in navigation menu, and are included in
the sitemap.

- Add PageLoader class to parse markdown with frontmatter
- Add /pages/{slug} route for rendering custom pages
- Add PAGES_HOME config setting to WebSettings
- Add prose CSS styles for markdown content
- Add pages to navigation and sitemap
- Update docker-compose.yml with pages volume mount
- Add comprehensive tests for PageLoader and routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:36:23 +00:00
Louis King
99206f7467 Updated README 2026-02-06 17:53:02 +00:00
Louis King
3a89daa9c0 Use empty Disallow in robots.txt for broader compatibility
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:58:52 +00:00
Louis King
86c5ff8f1c SEO fixes 2026-02-06 14:38:26 +00:00
JingleManSweep
59d0edc96f Merge pull request #76 from ipnet-mesh/chore/add-dynamic-sitemap-xml
Added dynamic XML sitemap for SEO
2026-02-06 12:53:20 +00:00
Louis King
b01611e0e8 Added dynamic XML sitemap for SEO 2026-02-06 12:50:40 +00:00
JingleManSweep
1e077f50f7 Merge pull request #75 from ipnet-mesh/chore/add-meshcore-text-seo
Updated SEO descriptions
2026-02-06 12:34:25 +00:00
Louis King
09146a2e94 Updated SEO descriptions 2026-02-06 12:31:40 +00:00
JingleManSweep
56487597b7 Merge pull request #73 from ipnet-mesh/chore/improve-seo
Added SEO optimisations
2026-02-06 12:21:30 +00:00
Louis King
de968f397d Added SEO optimisations 2026-02-06 12:17:27 +00:00
JingleManSweep
3ca5284c11 Merge pull request #72 from ipnet-mesh/chore/add-permissive-robots-txt
Added permissive robots.txt route
2026-02-06 12:12:20 +00:00
Louis King
75d7e5bdfa Added permissive robots.txt route 2026-02-06 12:09:36 +00:00
Louis King
927fcd6efb Fixed README and Docker Compose 2026-02-03 22:58:58 +00:00
JingleManSweep
3132d296bb Merge pull request #71 from ipnet-mesh/chore/fix-compose-profile
Fixed Compose dependencies and switched to Docker managed volume
2026-01-28 21:56:32 +00:00
Louis King
96e4215c29 Fixed Compose dependencies and switched to Docker managed volume 2026-01-28 21:53:36 +00:00
Louis King
fd3c3171ce Fix FastAPI response model for union return type 2026-01-26 22:29:13 +00:00
Louis King
345ffd219b Separate API prefix search from exact match endpoint
- Add /api/v1/nodes/prefix/{prefix} for prefix-based node lookup
- Change /api/v1/nodes/{public_key} to exact match only
- /n/{prefix} now simply redirects to /nodes/{prefix}
- /nodes/{key} resolves prefixes via API and redirects to full key
2026-01-26 22:27:15 +00:00
Louis King
9661b22390 Fix node detail 404 to use custom error page 2026-01-26 22:11:48 +00:00
Louis King
31aa48c9a0 Return 404 page when node not found in detail view 2026-01-26 22:08:01 +00:00
Louis King
1a3649b3be Revert "Simplify 404 page design"
This reverts commit 33649a065b.
2026-01-26 22:07:29 +00:00
Louis King
33649a065b Simplify 404 page design 2026-01-26 22:05:31 +00:00
Louis King
fd582bda35 Add custom 404 error page 2026-01-26 22:01:00 +00:00
Louis King
c42b26c8f3 Make /n/ short link resolve prefix to full public key 2026-01-26 21:57:04 +00:00
Louis King
d52163949a Change /n/ short link to redirect to /nodes/ 2026-01-26 21:48:55 +00:00
Louis King
ca101583f0 Add /n/ short link alias and simplify CI lint job
- Add /n/{public_key} route as alias for /nodes/{public_key} for shorter URLs
- Replace individual lint tools in CI with pre-commit/action for consistency
2026-01-26 21:41:33 +00:00
JingleManSweep
4af0f2ea80 Merge pull request #70 from ipnet-mesh/chore/node-page-prefix-support
Add prefix matching support to node API endpoint
2026-01-26 21:28:43 +00:00
Louis King
0b3ac64845 Add prefix matching support to node API endpoint
Allow users to navigate to a node using any prefix of its public key
instead of requiring the full 64-character key. If multiple nodes match
the prefix, the first one alphabetically is returned.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:27:36 +00:00
Louis King
3c7a8981ee Increased dedup bucket window to 120s 2026-01-17 20:15:46 +00:00
JingleManSweep
238e28ae41 Merge pull request #67 from ipnet-mesh/chore/tidyup-message-filters-columns
Message Filter and Table Tidying
2026-01-15 18:19:30 +00:00
Louis King
68d5049963 Removed pointless channel number filter and tidied column headings/values 2026-01-15 18:16:31 +00:00
JingleManSweep
624fa458ac Merge pull request #66 from ipnet-mesh/chore/fix-sqlite-path-exists
Ensure SQLite database path/subdirectories exist before initialising …
2026-01-15 17:36:58 +00:00
Louis King
309d575fc0 Ensure SQLite database path/subdirectories exist before initialising database 2026-01-15 17:32:56 +00:00
Louis King
f7b4df13a7 Added more test coverage 2026-01-12 21:00:02 +00:00
Louis King
13bae5c8d7 Added more test coverage 2026-01-12 20:34:53 +00:00
Louis King
8a6b4d8e88 Tidying 2026-01-12 20:02:45 +00:00
JingleManSweep
b67e1b5b2b Merge pull request #65 from ipnet-mesh/claude/plan-member-editor-BwkcS
Plan Member Editor for Organization Management
2026-01-12 19:59:32 +00:00
Louis King
d4e3dc0399 Local tweaks 2026-01-12 19:59:14 +00:00
Claude
7f0adfa6a7 Implement Member Editor admin interface
Add a complete CRUD interface for managing network members at /a/members,
following the proven pattern established by the Tag Editor.

Changes:
- Add member routes to admin.py (GET, POST create/update/delete)
- Create admin/members.html template with member table, forms, and modals
- Add Members navigation card to admin index page
- Include proper authentication checks and flash message handling
- Fix mypy type hints for optional form fields

The Member Editor allows admins to:
- View all network members in a sortable table
- Create new members with all fields (member_id, name, callsign, role, contact, description)
- Edit existing members via modal dialog
- Delete members with confirmation
- Client-side validation for member_id format (alphanumeric + underscore)

All backend API infrastructure (models, schemas, routes) was already implemented.
This is purely a web UI layer built on top of the existing /api/v1/members endpoints.
2026-01-12 19:41:56 +00:00
Claude
94b03b49d9 Add comprehensive Member Editor implementation plan
Create detailed plan for building a Member Editor admin interface at /a/members.
The plan follows the proven Tag Editor pattern and includes:

- Complete route structure for CRUD operations
- Full HTML template layout with modals and forms
- JavaScript event handlers for edit/delete actions
- Integration with existing Member API endpoints
- Testing checklist and acceptance criteria

All backend infrastructure (API, models, schemas) already exists.
This is purely a web UI implementation task estimated at 2-3 hours.
2026-01-12 19:33:13 +00:00
43 changed files with 3243 additions and 438 deletions

View File

@@ -18,20 +18,8 @@ jobs:
with:
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 }})

View File

@@ -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:*)'

View File

@@ -455,6 +455,7 @@ 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
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
@@ -472,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}/

235
README.md
View File

@@ -80,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
@@ -156,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:
@@ -175,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
@@ -205,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:
@@ -221,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]"
@@ -242,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:
@@ -319,11 +276,7 @@ All components are configured via environment variables. Create a `.env` file or
| `SERIAL_BAUD` | `115200` | Serial baud rate |
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
### Collector Settings
The database is stored in `{DATA_HOME}/collector/meshcore.db` by default.
#### Webhook Configuration
### Webhooks
The collector can forward certain events to external HTTP endpoints:
@@ -348,7 +301,7 @@ Webhook payload format:
}
```
#### Data Retention
### Data Retention
The collector automatically cleans up old event data and inactive nodes:
@@ -385,51 +338,65 @@ The collector automatically cleans up old event data and inactive nodes:
| `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
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 database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
### Running the Seed Process
#### Running the Seed Process
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
```
@@ -437,7 +404,7 @@ 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
#### Directory Structure
```
seed/ # SEED_HOME (seed data files)
@@ -451,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:
@@ -484,24 +451,11 @@ Tag values can be:
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
- member_id: walshie86
@@ -526,44 +480,6 @@ Network members represent the people operating nodes in your network. Members ca
| `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:
@@ -675,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

View File

@@ -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:
@@ -142,7 +142,7 @@ services:
db-migrate:
condition: service_completed_successfully
volumes:
- ${DATA_HOME:-./data}:/data
- hub_data:/data
- ${SEED_HOME:-./seed}:/seed
environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
@@ -154,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:-}
@@ -203,8 +201,7 @@ services:
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}
@@ -214,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:-}
@@ -246,6 +241,8 @@ 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
@@ -263,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')"]
@@ -286,12 +284,9 @@ services:
- 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"]
# ==========================================================================
@@ -309,20 +304,13 @@ services:
profiles:
- seed
restart: "no"
depends_on:
db-migrate:
condition: service_completed_successfully
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.yaml and members.yaml if they exist
command: ["collector", "seed"]
@@ -330,6 +318,8 @@ services:
# Volumes
# ==========================================================================
volumes:
hub_data:
name: meshcore_hub_data
mosquitto_data:
name: meshcore_mosquitto_data
mosquitto_log:

87
example/pages/join.md Normal file
View 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!

View File

@@ -39,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]
@@ -111,6 +113,8 @@ module = [
"uvicorn.*",
"alembic.*",
"meshcore.*",
"frontmatter.*",
"markdown.*",
]
ignore_missing_imports = true

View File

@@ -2,7 +2,7 @@
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Path, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
@@ -23,6 +23,7 @@ async def list_nodes(
),
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:
@@ -59,6 +60,16 @@ async def list_nodes(
)
)
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
@@ -77,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:

View File

@@ -295,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."""

View File

@@ -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)

View File

@@ -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.

View File

@@ -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__)
@@ -124,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")
@@ -150,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
@@ -166,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,
@@ -176,5 +291,6 @@ def get_network_context(request: Request) -> dict:
"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__,
}

View 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()

View File

@@ -3,25 +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"]

View File

@@ -391,3 +391,201 @@ async def admin_delete_all_tags(
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)

View File

@@ -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)

View File

@@ -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 "",
}
)

View File

@@ -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
@@ -81,25 +81,45 @@ async def nodes_list(
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}

View 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)

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145 120" width="145" height="120">
<!-- I letter - muted -->
<rect x="30" y="10" width="25" height="100" rx="2" fill="#ffffff" opacity="0.5"/>
<!-- P vertical stem -->
<rect x="65" y="10" width="25" height="100" rx="2" fill="#ffffff"/>
<!-- 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">
<path d="M 110,65 A 20,20 0 0,0 90,45"/>
<path d="M 125,65 A 35,35 0 0,0 90,30"/>
<path d="M 140,65 A 50,50 0 0,0 90,15"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -21,7 +21,8 @@
{% 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" />
<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>
@@ -29,7 +30,8 @@
{% 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" />
<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>
@@ -38,11 +40,26 @@
<!-- 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 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>

View 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 %}

View File

@@ -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">

View 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 %}

View File

@@ -1,89 +1,73 @@
{% 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-neutral">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Dashboard
</a>
<a href="/nodes" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Nodes
</a>
<a href="/advertisements" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Advertisements
</a>
<a href="/messages" class="btn btn-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
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">
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-32 w-32 mb-4" />
<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 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 gap-3 mt-auto">
<a href="/dashboard" class="btn btn-neutral">
{{ icon_dashboard("h-5 w-5 mr-2") }}
Dashboard
</a>
<a href="/map" class="btn btn-neutral">
{{ icon_map("h-5 w-5 mr-2") }}
Map
</a>
</div>
</div>
<!-- Stats Column (stacked vertically) -->
<div class="flex flex-col gap-4">
<!-- Total Nodes -->
<div class="stat bg-base-200 rounded-box relative">
<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>
<a href="/nodes" class="link link-primary text-sm absolute bottom-2 right-4">View Nodes</a>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<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>
<!-- Advertisements (7 days) -->
<div class="stat bg-base-200 rounded-box relative">
<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>
<a href="/advertisements" class="link link-secondary text-sm absolute bottom-2 right-4">View Adverts</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 (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>
<!-- Messages (7 days) -->
<div class="stat bg-base-200 rounded-box relative">
<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>
<a href="/messages" class="link link-accent text-sm absolute bottom-2 right-4">View Messages</a>
</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>

View 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 %}

View File

@@ -28,21 +28,10 @@
</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>
<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>
@@ -64,7 +53,7 @@
<div class="min-w-0">
<div class="font-medium text-sm truncate">
{% if msg.message_type == 'channel' %}
<span class="font-mono">CH{{ msg.channel_idx }}</span>
<span class="opacity-60">Public</span>
{% else %}
{% if msg.sender_tag_name or msg.sender_name %}
{{ msg.sender_tag_name or msg.sender_name }}
@@ -105,7 +94,7 @@
<tr>
<th>Type</th>
<th>Time</th>
<th>From/Channel</th>
<th>From</th>
<th>Message</th>
<th>Receivers</th>
</tr>
@@ -121,7 +110,7 @@
</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_tag_name or msg.sender_name %}
<span class="font-medium">{{ msg.sender_tag_name or msg.sender_name }}</span>
@@ -154,5 +143,5 @@
</table>
</div>
{{ pagination(page, total_pages, {"message_type": message_type, "channel_idx": channel_idx, "limit": limit}) }}
{{ pagination(page, total_pages, {"message_type": message_type, "limit": limit}) }}
{% endblock %}

View 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 %}

View File

@@ -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", "")

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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",
},

View 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

View File

@@ -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

View 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"]

View File

@@ -73,21 +73,27 @@ 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
@@ -98,8 +104,13 @@ class TestNodeDetailPage:
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:
@@ -123,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",
@@ -132,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

View 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