Compare commits

28 Commits

Author SHA1 Message Date
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
14 changed files with 341 additions and 263 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 }})

182
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:
@@ -226,8 +222,7 @@ SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
```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 +237,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 +263,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 +288,7 @@ Webhook payload format:
}
```
#### Data Retention
### Data Retention
The collector automatically cleans up old event data and inactive nodes:
@@ -386,50 +326,15 @@ The collector automatically cleans up old event data and inactive nodes:
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
## CLI Reference
```bash
# Show help
meshcore-hub --help
# Interface component
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
meshcore-hub interface --mode receiver --device-name "Gateway Node" # Set device name
meshcore-hub interface --mode sender --mock # Use mock device
# Collector component
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
# API component
meshcore-hub api --host 0.0.0.0 --port 8000
# Web dashboard
meshcore-hub web --port 8080 --network-name "My Network"
# Database management
meshcore-hub db upgrade # Run migrations
meshcore-hub db downgrade # Rollback one migration
meshcore-hub db current # Show current revision
```
## 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 +342,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 +356,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 +389,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 +418,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:

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:-}
@@ -286,12 +281,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 +301,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 +315,8 @@ services:
# Volumes
# ==========================================================================
volumes:
hub_data:
name: meshcore_hub_data
mosquitto_data:
name: meshcore_mosquitto_data
mosquitto_log:

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

@@ -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,8 +7,10 @@ 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
@@ -150,6 +152,98 @@ 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"),
("/network", "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}")
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

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

@@ -5,6 +5,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/meshcore.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>

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

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

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

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

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