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

|
||||
|
||||
@@ -17,41 +17,45 @@ MeshCore Hub provides a complete solution for monitoring, collecting, and intera
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MeshCore │ │ MeshCore │ │ MeshCore │
|
||||
│ Device 1 │ │ Device 2 │ │ Device 3 │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ Serial/USB │ Serial/USB │ Serial/USB
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Interface │ │ Interface │ │ Interface │
|
||||
│ (RECEIVER) │ │ (RECEIVER) │ │ (SENDER) │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────▲────────┘
|
||||
│ │ │
|
||||
│ Publish │ Publish │ Subscribe
|
||||
│ │ │
|
||||
└───────────┬───────────┴───────────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ MQTT │
|
||||
│ Broker │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Collector │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Database │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌───────▼───────┐
|
||||
│ API │◄──────│ Web Dashboard │
|
||||
└─────────────┘ └───────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Devices["MeshCore Devices"]
|
||||
D1["Device 1"]
|
||||
D2["Device 2"]
|
||||
D3["Device 3"]
|
||||
end
|
||||
|
||||
subgraph Interfaces["Interface Layer"]
|
||||
I1["RECEIVER"]
|
||||
I2["RECEIVER"]
|
||||
I3["SENDER"]
|
||||
end
|
||||
|
||||
D1 -->|Serial| I1
|
||||
D2 -->|Serial| I2
|
||||
D3 -->|Serial| I3
|
||||
|
||||
I1 -->|Publish| MQTT
|
||||
I2 -->|Publish| MQTT
|
||||
MQTT -->|Subscribe| I3
|
||||
|
||||
MQTT["MQTT Broker"]
|
||||
|
||||
subgraph Backend["Backend Services"]
|
||||
Collector --> Database --> API
|
||||
end
|
||||
|
||||
MQTT --> Collector
|
||||
API --> Web["Web Dashboard"]
|
||||
|
||||
style Devices fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Interfaces fill:none,stroke:#f57c00,stroke-width:2px
|
||||
style Backend fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style Database fill:none,stroke:#c2185b,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
## Features
|
||||
@@ -76,9 +80,13 @@ The quickest way to get started is running the entire stack on a single machine
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
# Create a directory, download the Docker Compose file and
|
||||
# example environment configuration file
|
||||
|
||||
mkdir meshcore-hub
|
||||
cd meshcore-hub
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/.env.example
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
@@ -97,33 +105,34 @@ This starts all services: MQTT broker, collector, API, web dashboard, and the in
|
||||
|
||||
For larger deployments, you can separate receiver nodes from the central infrastructure. This allows multiple community members to contribute receiver coverage while hosting the backend centrally.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community Members │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Raspberry Pi │ │ Raspberry Pi │ │ Any Linux │ │
|
||||
│ │ + MeshCore │ │ + MeshCore │ │ + MeshCore │ │
|
||||
│ │ Device │ │ Device │ │ Device │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ receiver profile only │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ │ │
|
||||
│ MQTT (port 1883) │
|
||||
│ │ │
|
||||
└────────────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Community VPS / Server │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ MQTT │──▶│ Collector │──▶│ API │◀──│ Web Dashboard│ │
|
||||
│ │ Broker │ │ │ │ │ │ (public) │ │
|
||||
│ └──────────┘ └───────────┘ └─────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Community["Community Members"]
|
||||
R1["Raspberry Pi + MeshCore"]
|
||||
R2["Raspberry Pi + MeshCore"]
|
||||
R3["Any Linux + MeshCore"]
|
||||
end
|
||||
|
||||
subgraph Server["Community VPS / Server"]
|
||||
MQTT["MQTT Broker"]
|
||||
Collector
|
||||
API
|
||||
Web["Web Dashboard (public)"]
|
||||
|
||||
MQTT --> Collector --> API
|
||||
API <--- Web
|
||||
end
|
||||
|
||||
R1 -->|MQTT port 1883| MQTT
|
||||
R2 -->|MQTT port 1883| MQTT
|
||||
R3 -->|MQTT port 1883| MQTT
|
||||
|
||||
style Community fill:none,stroke:#0288d1,stroke-width:2px
|
||||
style Server fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
|
||||
style Collector fill:none,stroke:#388e3c,stroke-width:2px
|
||||
style API fill:none,stroke:#1976d2,stroke-width:2px
|
||||
style Web fill:none,stroke:#ffa000,stroke-width:2px
|
||||
```
|
||||
|
||||
**On each receiver node (Raspberry Pi, etc.):**
|
||||
@@ -151,9 +160,9 @@ This architecture allows:
|
||||
- Community members to contribute coverage with minimal setup
|
||||
- The central server to be hosted anywhere with internet access
|
||||
|
||||
## Quick Start
|
||||
## Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Docker Compose Profiles
|
||||
|
||||
Docker Compose uses **profiles** to select which services to run:
|
||||
|
||||
@@ -170,14 +179,6 @@ Docker Compose uses **profiles** to select which services to run:
|
||||
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
cd meshcore-hub
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings (API keys, serial port, network info)
|
||||
|
||||
# Create database schema
|
||||
docker compose --profile migrate run --rm db-migrate
|
||||
|
||||
@@ -200,7 +201,7 @@ docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
#### Serial Device Access
|
||||
### Serial Device Access
|
||||
|
||||
For production with real MeshCore devices, ensure the serial port is accessible:
|
||||
|
||||
@@ -216,13 +217,25 @@ SERIAL_PORT=/dev/ttyUSB0
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
|
||||
```
|
||||
|
||||
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
|
||||
|
||||
```bash
|
||||
# List available devices by ID
|
||||
ls -la /dev/serial/by-id/
|
||||
|
||||
# Example output:
|
||||
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
|
||||
|
||||
# Configure using the stable ID
|
||||
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the package
|
||||
pip install -e ".[dev]"
|
||||
@@ -237,57 +250,6 @@ meshcore-hub api
|
||||
meshcore-hub web
|
||||
```
|
||||
|
||||
## Updating an Existing Installation
|
||||
|
||||
To update MeshCore Hub to the latest version:
|
||||
|
||||
```bash
|
||||
# Navigate to your installation directory
|
||||
cd meshcore-hub
|
||||
|
||||
# Pull the latest code
|
||||
git pull
|
||||
|
||||
# Pull latest Docker images
|
||||
docker compose --profile all pull
|
||||
|
||||
# Recreate and restart services
|
||||
# For receiver/sender only installs:
|
||||
docker compose --profile receiver up -d --force-recreate
|
||||
|
||||
# For core services with MQTT:
|
||||
docker compose --profile mqtt --profile core up -d --force-recreate
|
||||
|
||||
# For core services without local MQTT:
|
||||
docker compose --profile core up -d --force-recreate
|
||||
|
||||
# For complete stack (all services):
|
||||
docker compose --profile mqtt --profile core --profile receiver up -d --force-recreate
|
||||
|
||||
# View logs to verify update
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
**Note:** Database migrations run automatically on collector startup, so no manual migration step is needed when using Docker.
|
||||
|
||||
For manual installations:
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Update dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run database migrations
|
||||
meshcore-hub db upgrade
|
||||
|
||||
# Restart your services
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All components are configured via environment variables. Create a `.env` file or export variables:
|
||||
@@ -297,28 +259,24 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `DATA_HOME` | `./data` | Base directory for runtime data |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files |
|
||||
| `MQTT_HOST` | `localhost` | MQTT broker hostname |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `MQTT_USERNAME` | *(none)* | MQTT username (optional) |
|
||||
| `MQTT_PASSWORD` | *(none)* | MQTT password (optional) |
|
||||
| `MQTT_PREFIX` | `meshcore` | Topic prefix for all MQTT messages |
|
||||
| `MQTT_TLS` | `false` | Enable TLS/SSL for MQTT connection |
|
||||
|
||||
### Interface Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERFACE_MODE` | `RECEIVER` | Operating mode (RECEIVER or SENDER) |
|
||||
| `SERIAL_PORT` | `/dev/ttyUSB0` | Serial port for MeshCore device |
|
||||
| `SERIAL_BAUD` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
|
||||
| `MOCK_DEVICE` | `false` | Use mock device for testing |
|
||||
|
||||
### Collector Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite:///{data_home}/collector/meshcore.db` | SQLAlchemy database URL |
|
||||
| `SEED_HOME` | `./seed` | Directory containing seed data files (node_tags.yaml, members.yaml) |
|
||||
|
||||
#### Webhook Configuration
|
||||
### Webhooks
|
||||
|
||||
The collector can forward certain events to external HTTP endpoints:
|
||||
|
||||
@@ -343,6 +301,18 @@ Webhook payload format:
|
||||
}
|
||||
```
|
||||
|
||||
### Data Retention
|
||||
|
||||
The collector automatically cleans up old event data and inactive nodes:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATA_RETENTION_ENABLED` | `true` | Enable automatic cleanup of old events |
|
||||
| `DATA_RETENTION_DAYS` | `30` | Days to retain event data |
|
||||
| `DATA_RETENTION_INTERVAL_HOURS` | `24` | Hours between cleanup runs |
|
||||
| `NODE_CLEANUP_ENABLED` | `true` | Enable removal of inactive nodes |
|
||||
| `NODE_CLEANUP_DAYS` | `7` | Remove nodes not seen for this many days |
|
||||
|
||||
### API Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -359,62 +329,92 @@ Webhook payload format:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
| `NETWORK_RADIO_CONFIG` | *(none)* | Radio config (comma-delimited: profile,freq,bw,sf,cr,power) |
|
||||
| `NETWORK_WELCOME_TEXT` | *(none)* | Custom welcome text for homepage |
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
## CLI Reference
|
||||
### Custom Content
|
||||
|
||||
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
|
||||
|
||||
```
|
||||
content/
|
||||
├── pages/ # Custom markdown pages
|
||||
│ └── about.md
|
||||
└── media/ # Custom media files
|
||||
└── images/
|
||||
└── logo.svg # Custom logo (replaces favicon and navbar/home logo)
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Show help
|
||||
meshcore-hub --help
|
||||
# Create content directory structure
|
||||
mkdir -p content/pages content/media
|
||||
|
||||
# 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 > content/pages/about.md << 'EOF'
|
||||
---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Collector component
|
||||
meshcore-hub collector # Run collector (auto-seeds on startup)
|
||||
meshcore-hub collector seed # Import all seed data from SEED_HOME
|
||||
meshcore-hub collector import-tags # Import node tags from SEED_HOME/node_tags.yaml
|
||||
meshcore-hub collector import-tags /path/to/file.yaml # Import from specific file
|
||||
meshcore-hub collector import-members # Import members from SEED_HOME/members.yaml
|
||||
meshcore-hub collector import-members /path/to/file.yaml # Import from specific file
|
||||
# About Our Network
|
||||
|
||||
# API component
|
||||
meshcore-hub api --host 0.0.0.0 --port 8000
|
||||
Welcome to our MeshCore mesh network!
|
||||
|
||||
# Web dashboard
|
||||
meshcore-hub web --port 8080 --network-name "My Network"
|
||||
## Getting Started
|
||||
|
||||
# Database management
|
||||
meshcore-hub db upgrade # Run migrations
|
||||
meshcore-hub db downgrade # Rollback one migration
|
||||
meshcore-hub db current # Show current revision
|
||||
1. Get a compatible LoRa device
|
||||
2. Flash MeshCore firmware
|
||||
3. Configure your radio settings
|
||||
EOF
|
||||
```
|
||||
|
||||
**Frontmatter fields:**
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `title` | Filename titlecased | Browser tab title and navigation link text (not rendered on page) |
|
||||
| `slug` | Filename without `.md` | URL path (e.g., `about` → `/pages/about`) |
|
||||
| `menu_order` | `100` | Sort order in navigation (lower = earlier) |
|
||||
|
||||
The markdown content is rendered as-is, so include your own `# Heading` if desired.
|
||||
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the content directory:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (already configured)
|
||||
volumes:
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- CONTENT_HOME=/content
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
|
||||
The collector supports seeding the database with node tags and network members on startup. Seed files are read from the `SEED_HOME` directory (default: `./seed`).
|
||||
The database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
|
||||
### Automatic Seeding
|
||||
#### Running the Seed Process
|
||||
|
||||
When the collector starts, it automatically imports seed data from YAML files if they exist:
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
### Manual Seeding
|
||||
Seeding is a separate process and must be run explicitly:
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
This imports data from the following files (if they exist):
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
#### Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
@@ -428,11 +428,11 @@ data/ # DATA_HOME (runtime data)
|
||||
|
||||
Example seed files are provided in `example/seed/`.
|
||||
|
||||
## Node Tags
|
||||
### Node Tags
|
||||
|
||||
Node tags allow you to attach custom metadata to nodes (e.g., location, role, owner). Tags are stored in the database and returned with node data via the API.
|
||||
|
||||
### Node Tags YAML Format
|
||||
#### Node Tags YAML Format
|
||||
|
||||
Tags are keyed by public key in YAML format:
|
||||
|
||||
@@ -448,53 +448,41 @@ Tags are keyed by public key in YAML format:
|
||||
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
|
||||
friendly_name: Oakland Repeater
|
||||
altitude: 150
|
||||
location:
|
||||
value: "37.8044,-122.2712"
|
||||
type: coordinate
|
||||
```
|
||||
|
||||
Tag values can be:
|
||||
- **YAML primitives** (auto-detected type): strings, numbers, booleans
|
||||
- **Explicit type** (for special types like coordinate):
|
||||
- **Explicit type** (when you need to force a specific type):
|
||||
```yaml
|
||||
location:
|
||||
value: "37.7749,-122.4194"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
```
|
||||
|
||||
Supported types: `string`, `number`, `boolean`, `coordinate`
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
### Import Tags Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/node_tags.yaml)
|
||||
meshcore-hub collector import-tags
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-tags /path/to/node_tags.yaml
|
||||
|
||||
# Skip tags for nodes that don't exist
|
||||
meshcore-hub collector import-tags --no-create-nodes
|
||||
```
|
||||
|
||||
## Network Members
|
||||
### Network Members
|
||||
|
||||
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
|
||||
|
||||
### Members YAML Format
|
||||
#### Members YAML Format
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- name: John Doe
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member entry
|
||||
contact: john@example.com
|
||||
public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
- member_id: walshie86
|
||||
name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
- member_id: craig
|
||||
name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `member_id` | Yes | Unique identifier for the member |
|
||||
| `name` | Yes | Member's display name |
|
||||
| `callsign` | No | Amateur radio callsign |
|
||||
| `role` | No | Member's role in the network |
|
||||
@@ -502,44 +490,6 @@ members:
|
||||
| `contact` | No | Contact information |
|
||||
| `public_key` | No | Associated node public key (64-char hex) |
|
||||
|
||||
### Import Members Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/members.yaml)
|
||||
meshcore-hub collector import-members
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-members /path/to/members.yaml
|
||||
```
|
||||
|
||||
### Managing Tags via API
|
||||
|
||||
Tags can also be managed via the REST API:
|
||||
|
||||
```bash
|
||||
# List tags for a node
|
||||
curl http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Create a tag (requires admin key)
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "location", "value": "Building A"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Update a tag
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": "Building B"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
|
||||
# Delete a tag
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
When running, the API provides interactive documentation at:
|
||||
@@ -620,14 +570,8 @@ pytest -k "test_list"
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint
|
||||
flake8 src/ tests/
|
||||
|
||||
# Type check
|
||||
mypy src/
|
||||
# Run all code quality checks (formatting, linting, type checking)
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Creating Database Migrations
|
||||
@@ -657,10 +601,19 @@ 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
|
||||
│ └── content/ # Example custom content
|
||||
│ ├── pages/ # Example custom pages
|
||||
│ │ └── about.md # Example about page
|
||||
│ └── media/ # Example media files
|
||||
│ └── images/ # Custom images
|
||||
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
|
||||
├── content/ # Custom content directory (CONTENT_HOME, optional)
|
||||
│ ├── pages/ # Custom markdown pages
|
||||
│ └── media/ # Custom media files
|
||||
│ └── images/ # Custom images (logo.svg replaces default logo)
|
||||
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
|
||||
├── Dockerfile # Docker build configuration
|
||||
├── docker-compose.yml # Docker Compose services
|
||||
@@ -684,7 +637,7 @@ meshcore-hub/
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests and linting (`pytest && black . && flake8`)
|
||||
4. Run tests and quality checks (`pytest && pre-commit run --all-files`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add lat lon columns to nodes
|
||||
|
||||
Revision ID: 4e2e787a1660
|
||||
Revises: aa1162502616
|
||||
Create Date: 2026-01-09 20:04:04.273741+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4e2e787a1660"
|
||||
down_revision: Union[str, None] = "aa1162502616"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_column("lon")
|
||||
batch_op.drop_column("lat")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- "${MQTT_EXTERNAL_PORT:-1883}:1883"
|
||||
- "${MQTT_WS_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
# - ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_log:/mosquitto/log
|
||||
healthcheck:
|
||||
@@ -139,10 +139,10 @@ services:
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
seed:
|
||||
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:-}
|
||||
@@ -196,15 +194,14 @@ services:
|
||||
- core
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
seed:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
collector:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -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,12 +241,17 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
# Use ADMIN key to allow write operations from admin interface
|
||||
# Falls back to READ key if ADMIN key is not set
|
||||
- API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
@@ -259,7 +259,10 @@ services:
|
||||
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
||||
- NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-}
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- CONTENT_HOME=/content
|
||||
- TZ=${TZ:-UTC}
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
@@ -283,16 +286,16 @@ 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"]
|
||||
|
||||
# ==========================================================================
|
||||
# Seed Data - Import node_tags.json and members.json from SEED_HOME
|
||||
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
|
||||
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
|
||||
# Since tags are now managed via the admin UI, automatic seeding would
|
||||
# overwrite user changes.
|
||||
# ==========================================================================
|
||||
seed:
|
||||
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
||||
@@ -301,31 +304,24 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: meshcore-seed
|
||||
profiles:
|
||||
- all
|
||||
- core
|
||||
- seed
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
# Mount data directory for database (read-write)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
# Mount seed directory for seed files (read-only)
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed:ro
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Imports both node_tags.json and members.json if they exist
|
||||
# Imports both node_tags.yaml and members.yaml if they exist
|
||||
command: ["collector", "seed"]
|
||||
|
||||
# ==========================================================================
|
||||
# Volumes
|
||||
# ==========================================================================
|
||||
volumes:
|
||||
hub_data:
|
||||
name: meshcore_hub_data
|
||||
mosquitto_data:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 124 KiB |
61
example/content/media/images/logo_ipnet.svg
Normal file
61
example/content/media/images/logo_ipnet.svg
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 115 100"
|
||||
width="115"
|
||||
height="100"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="logo-dark.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- I letter - muted -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
opacity="0.5"
|
||||
id="rect1" />
|
||||
<!-- P vertical stem -->
|
||||
<rect
|
||||
x="35"
|
||||
y="0"
|
||||
width="25"
|
||||
height="100"
|
||||
rx="2"
|
||||
fill="#ffffff"
|
||||
id="rect2" />
|
||||
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
id="g4"
|
||||
transform="translate(-30,-10)">
|
||||
<path
|
||||
d="M 110,65 A 20,20 0 0 0 90,45"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M 125,65 A 35,35 0 0 0 90,30"
|
||||
id="path3" />
|
||||
<path
|
||||
d="M 140,65 A 50,50 0 0 0 90,15"
|
||||
id="path4" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
87
example/content/pages/join.md
Normal file
87
example/content/pages/join.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Join
|
||||
slug: join
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# Getting Started with MeshCore
|
||||
|
||||
MeshCore is an open-source off-grid LoRa mesh networking platform. This guide will help you get connected to the network.
|
||||
|
||||
For detailed documentation, see the [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md).
|
||||
|
||||
## Node Types
|
||||
|
||||
MeshCore devices operate in different modes:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Companion** | Connects to your phone via Bluetooth. Use this for messaging and interacting with the network. |
|
||||
| **Repeater** | Standalone node that extends network coverage. Place these in elevated locations for best results. |
|
||||
| **Room Server** | Hosts chat rooms that persist messages for offline users. |
|
||||
|
||||
Most users start with a **Companion** node paired to their phone.
|
||||
|
||||
## Frequency Regulations
|
||||
|
||||
MeshCore uses LoRa radio, which operates on unlicensed ISM bands. You **must** use the correct frequency for your region:
|
||||
|
||||
| Region | Frequency | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Europe (EU) | 868 MHz | EU868 band |
|
||||
| United Kingdom | 868 MHz | Same as EU |
|
||||
| North America | 915 MHz | US915 band |
|
||||
| Australia | 915 MHz | AU915 band |
|
||||
|
||||
Using the wrong frequency is illegal and may cause interference. Check your local regulations.
|
||||
|
||||
## Compatible Hardware
|
||||
|
||||
MeshCore runs on inexpensive low-power LoRa devices. Popular options include:
|
||||
|
||||
### Recommended Devices
|
||||
|
||||
| Device | Manufacturer | Features |
|
||||
|--------|--------------|----------|
|
||||
| [Heltec V3](https://heltec.org/project/wifi-lora-32-v3/) | Heltec | Budget-friendly, OLED display |
|
||||
| [T114](https://heltec.org/project/mesh-node-t114/) | Heltec | Compact, GPS, colour display |
|
||||
| [T1000-E](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | Seeed Studio | Credit-card sized, GPS, weatherproof |
|
||||
| [T-Deck Plus](https://www.lilygo.cc/products/t-deck-plus) | LilyGO | Built-in keyboard, touchscreen, GPS |
|
||||
|
||||
Ensure you purchase the correct frequency variant (868MHz for EU/UK, 915MHz for US/AU).
|
||||
|
||||
### Where to Buy
|
||||
|
||||
- **Heltec**: [Official Store](https://heltec.org/) or AliExpress
|
||||
- **LilyGO**: [Official Store](https://lilygo.cc/) or AliExpress
|
||||
- **Seeed Studio**: [Official Store](https://www.seeedstudio.com/)
|
||||
- **Amazon**: Search for device name + "LoRa 868" (or 915 for US)
|
||||
|
||||
## Mobile Apps
|
||||
|
||||
Connect to your Companion node using the official MeshCore apps:
|
||||
|
||||
| Platform | App | Link |
|
||||
|----------|-----|------|
|
||||
| Android | MeshCore | [Google Play](https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android) |
|
||||
| iOS | MeshCore | [App Store](https://apps.apple.com/us/app/meshcore/id6742354151) |
|
||||
|
||||
The app connects via Bluetooth to your Companion node, allowing you to send messages, view the network, and configure your device.
|
||||
|
||||
## Flashing Firmware
|
||||
|
||||
1. Use the [MeshCore Web Flasher](https://flasher.meshcore.co.uk/) for easy browser-based flashing
|
||||
2. Select your device type and region (frequency)
|
||||
3. Connect via USB and flash
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once your device is flashed and paired:
|
||||
|
||||
1. Open the MeshCore app on your phone
|
||||
2. Enable Bluetooth and pair with your device
|
||||
3. Set your node name in the app settings
|
||||
4. Configure your radio settings/profile for your region
|
||||
4. You should start seeing other nodes on the network
|
||||
|
||||
Welcome to the mesh!
|
||||
@@ -7,12 +7,12 @@
|
||||
# elevation: 150 # number
|
||||
# is_online: true # boolean
|
||||
#
|
||||
# - Explicit type (for special types like coordinate):
|
||||
# location:
|
||||
# value: "37.7749,-122.4194"
|
||||
# type: coordinate
|
||||
# - Explicit type (when you need to force a specific type):
|
||||
# altitude:
|
||||
# value: "150"
|
||||
# type: number
|
||||
#
|
||||
# Supported types: string, number, boolean, coordinate
|
||||
# Supported types: string, number, boolean
|
||||
|
||||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:
|
||||
friendly_name: Gateway Node
|
||||
|
||||
@@ -8,7 +8,7 @@ version = "0.0.0"
|
||||
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.13"
|
||||
authors = [
|
||||
{name = "MeshCore Hub Contributors"}
|
||||
]
|
||||
@@ -18,8 +18,7 @@ classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
@@ -40,6 +39,8 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"meshcore>=2.2.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -78,7 +79,7 @@ meshcore_hub = ["py.typed"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py311"]
|
||||
target-version = ["py312"]
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
@@ -97,7 +98,7 @@ extend-exclude = '''
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
python_version = "3.13"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
@@ -112,6 +113,8 @@ module = [
|
||||
"uvicorn.*",
|
||||
"alembic.*",
|
||||
"meshcore.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ async def list_advertisements(
|
||||
received_by: Optional[str] = Query(
|
||||
None, description="Filter by receiver node public key"
|
||||
),
|
||||
member_id: Optional[str] = Query(
|
||||
None, description="Filter by member_id tag value of source node"
|
||||
),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
@@ -143,6 +146,16 @@ async def list_advertisements(
|
||||
if received_by:
|
||||
query = query.where(ReceiverNode.public_key == received_by)
|
||||
|
||||
if member_id:
|
||||
# Filter advertisements from nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
|
||||
@@ -228,15 +228,15 @@ async def get_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily advertisement counts for each day in the period
|
||||
Daily advertisement counts for each day in the period (excluding today)
|
||||
"""
|
||||
# Limit to max 90 days
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query advertisement counts grouped by date
|
||||
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
|
||||
@@ -248,6 +248,7 @@ async def get_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Advertisement.received_at >= start_date)
|
||||
.where(Advertisement.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -280,14 +281,14 @@ async def get_message_activity(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Daily message counts for each day in the period
|
||||
Daily message counts for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query message counts grouped by date
|
||||
date_expr = func.date(Message.received_at)
|
||||
@@ -298,6 +299,7 @@ async def get_message_activity(
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Message.received_at >= start_date)
|
||||
.where(Message.received_at < end_date)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
@@ -331,14 +333,14 @@ async def get_node_count_history(
|
||||
days: Number of days to include (default 30, max 90)
|
||||
|
||||
Returns:
|
||||
Cumulative node count for each day in the period
|
||||
Cumulative node count for each day in the period (excluding today)
|
||||
"""
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = (now - timedelta(days=days - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Get all nodes with their creation dates
|
||||
# Count nodes created on or before each date
|
||||
|
||||
@@ -6,7 +6,13 @@ from sqlalchemy import select
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
|
||||
from meshcore_hub.common.schemas.nodes import (
|
||||
NodeTagCreate,
|
||||
NodeTagMove,
|
||||
NodeTagRead,
|
||||
NodeTagsCopyResult,
|
||||
NodeTagUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -130,6 +136,131 @@ async def update_node_tag(
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.put("/nodes/{public_key}/tags/{key}/move", response_model=NodeTagRead)
|
||||
async def move_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
data: NodeTagMove,
|
||||
) -> NodeTagRead:
|
||||
"""Move a node tag to a different node."""
|
||||
# Check if source and destination are the same
|
||||
if public_key == data.new_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == source_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == data.new_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Check if tag already exists on destination node
|
||||
conflict_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == dest_node.id) & (NodeTag.key == key)
|
||||
)
|
||||
conflict = session.execute(conflict_query).scalar_one_or_none()
|
||||
|
||||
if conflict:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Tag '{key}' already exists on destination node",
|
||||
)
|
||||
|
||||
# Move tag to destination node
|
||||
node_tag.node_id = dest_node.id
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
response_model=NodeTagsCopyResult,
|
||||
)
|
||||
async def copy_all_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
dest_public_key: str,
|
||||
) -> NodeTagsCopyResult:
|
||||
"""Copy all tags from one node to another.
|
||||
|
||||
Tags that already exist on the destination node are skipped.
|
||||
"""
|
||||
# Check if source and destination are the same
|
||||
if public_key == dest_public_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Source and destination nodes are the same",
|
||||
)
|
||||
|
||||
# Find source node
|
||||
source_query = select(Node).where(Node.public_key == public_key)
|
||||
source_node = session.execute(source_query).scalar_one_or_none()
|
||||
|
||||
if not source_node:
|
||||
raise HTTPException(status_code=404, detail="Source node not found")
|
||||
|
||||
# Find destination node
|
||||
dest_query = select(Node).where(Node.public_key == dest_public_key)
|
||||
dest_node = session.execute(dest_query).scalar_one_or_none()
|
||||
|
||||
if not dest_node:
|
||||
raise HTTPException(status_code=404, detail="Destination node not found")
|
||||
|
||||
# Get existing tags on destination node
|
||||
existing_query = select(NodeTag.key).where(NodeTag.node_id == dest_node.id)
|
||||
existing_keys = set(session.execute(existing_query).scalars().all())
|
||||
|
||||
# Copy tags
|
||||
copied = 0
|
||||
skipped_keys = []
|
||||
|
||||
for tag in source_node.tags:
|
||||
if tag.key in existing_keys:
|
||||
skipped_keys.append(tag.key)
|
||||
continue
|
||||
|
||||
new_tag = NodeTag(
|
||||
node_id=dest_node.id,
|
||||
key=tag.key,
|
||||
value=tag.value,
|
||||
value_type=tag.value_type,
|
||||
)
|
||||
session.add(new_tag)
|
||||
copied += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
return NodeTagsCopyResult(
|
||||
copied=copied,
|
||||
skipped=len(skipped_keys),
|
||||
skipped_keys=skipped_keys,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
|
||||
async def delete_node_tag(
|
||||
_: RequireAdmin,
|
||||
@@ -156,3 +287,27 @@ async def delete_node_tag(
|
||||
|
||||
session.delete(node_tag)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags")
|
||||
async def delete_all_node_tags(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> dict:
|
||||
"""Delete all tags for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Count and delete all tags
|
||||
count = len(node.tags)
|
||||
for tag in node.tags:
|
||||
session.delete(tag)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"deleted": count}
|
||||
|
||||
@@ -2,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
|
||||
|
||||
@@ -22,6 +22,8 @@ async def list_nodes(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
role: Optional[str] = Query(None, description="Filter by role tag value"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
@@ -48,6 +50,26 @@ async def list_nodes(
|
||||
if adv_type:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
if member_id:
|
||||
# Filter nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# Filter nodes that have a role tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "role", NodeTag.value == role
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
@@ -66,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:
|
||||
|
||||
@@ -433,12 +433,12 @@ def import_tags_cmd(
|
||||
\b
|
||||
0123456789abcdef...:
|
||||
friendly_name: My Node
|
||||
location:
|
||||
value: "52.0,1.0"
|
||||
type: coordinate
|
||||
altitude:
|
||||
value: "150"
|
||||
type: number
|
||||
active:
|
||||
value: "true"
|
||||
type: boolean
|
||||
|
||||
Shorthand is also supported (string values with default type):
|
||||
|
||||
@@ -447,7 +447,7 @@ def import_tags_cmd(
|
||||
friendly_name: My Node
|
||||
role: gateway
|
||||
|
||||
Supported types: string, number, boolean, coordinate
|
||||
Supported types: string, number, boolean
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_contact(
|
||||
# Device uses 'adv_name' for the advertised name
|
||||
name = payload.get("adv_name") or payload.get("name")
|
||||
|
||||
# GPS coordinates (optional)
|
||||
lat = payload.get("adv_lat")
|
||||
lon = payload.get("adv_lon")
|
||||
|
||||
logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}")
|
||||
|
||||
# Device uses numeric 'type' field, convert to string
|
||||
@@ -73,6 +77,11 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
# Update GPS coordinates if provided
|
||||
if lat is not None:
|
||||
node.lat = lat
|
||||
if lon is not None:
|
||||
node.lon = lon
|
||||
# Do NOT update last_seen for contact sync - only advertisement events
|
||||
# should update last_seen since that's when the node was actually seen
|
||||
else:
|
||||
@@ -84,6 +93,8 @@ def handle_contact(
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=None, # Will be set when we receive an advertisement
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -19,7 +19,7 @@ class TagValue(BaseModel):
|
||||
"""Schema for a tag value with type."""
|
||||
|
||||
value: str | None = None
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean|coordinate)$")
|
||||
type: str = Field(default="string", pattern=r"^(string|number|boolean)$")
|
||||
|
||||
|
||||
class NodeTags(BaseModel):
|
||||
|
||||
@@ -78,6 +78,17 @@ class InterfaceSettings(CommonSettings):
|
||||
default=None, description="Device/node name (optional)"
|
||||
)
|
||||
|
||||
# Contact cleanup settings
|
||||
contact_cleanup_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable automatic removal of stale contacts from companion node",
|
||||
)
|
||||
contact_cleanup_days: int = Field(
|
||||
default=7,
|
||||
description="Remove contacts not advertised for this many days",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class CollectorSettings(CommonSettings):
|
||||
"""Settings for the Collector component."""
|
||||
@@ -242,6 +253,15 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# Timezone for date/time display (uses standard TZ environment variable)
|
||||
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
|
||||
)
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
@@ -274,10 +294,40 @@ class WebSettings(CommonSettings):
|
||||
network_contact_github: Optional[str] = Field(
|
||||
default=None, description="GitHub repository URL"
|
||||
)
|
||||
network_contact_youtube: Optional[str] = Field(
|
||||
default=None, description="YouTube channel URL"
|
||||
)
|
||||
network_welcome_text: Optional[str] = Field(
|
||||
default=None, description="Welcome text for homepage"
|
||||
)
|
||||
|
||||
# Content directory (contains pages/ and media/ subdirectories)
|
||||
content_home: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Directory containing custom content (pages/, media/) (default: ./content)",
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_content_home(self) -> str:
|
||||
"""Get the effective content home directory."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.content_home or "./content"))
|
||||
|
||||
@property
|
||||
def effective_pages_home(self) -> str:
|
||||
"""Get the effective pages directory (content_home/pages)."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_content_home) / "pages")
|
||||
|
||||
@property
|
||||
def effective_media_home(self) -> str:
|
||||
"""Get the effective media directory (content_home/media)."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_content_home) / "media")
|
||||
|
||||
@property
|
||||
def web_data_dir(self) -> str:
|
||||
"""Get the web data directory path."""
|
||||
|
||||
@@ -98,6 +98,15 @@ class DatabaseManager:
|
||||
echo: Enable SQL query logging
|
||||
"""
|
||||
self.database_url = database_url
|
||||
|
||||
# Ensure parent directory exists for SQLite databases
|
||||
if database_url.startswith("sqlite:///"):
|
||||
from pathlib import Path
|
||||
|
||||
# Extract path from sqlite:///path/to/db.sqlite
|
||||
db_path = Path(database_url.replace("sqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.engine = create_database_engine(database_url, echo=echo)
|
||||
self.session_factory = create_session_factory(self.engine)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def compute_advertisement_hash(
|
||||
adv_type: Optional[str] = None,
|
||||
flags: Optional[int] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for an advertisement.
|
||||
|
||||
@@ -104,7 +104,7 @@ def compute_telemetry_hash(
|
||||
node_public_key: str,
|
||||
parsed_data: Optional[dict] = None,
|
||||
received_at: Optional[datetime] = None,
|
||||
bucket_seconds: int = 30,
|
||||
bucket_seconds: int = 120,
|
||||
) -> str:
|
||||
"""Compute a deterministic hash for a telemetry record.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
@@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
lat: GPS latitude coordinate (if available)
|
||||
lon: GPS longitude coordinate (if available)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -57,6 +59,14 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
lat: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
lon: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tags: Mapped[list["NodeTag"]] = relationship(
|
||||
|
||||
@@ -21,7 +21,7 @@ class NodeTag(Base, UUIDMixin, TimestampMixin):
|
||||
node_id: Foreign key to nodes table
|
||||
key: Tag name/key
|
||||
value: Tag value (stored as text, can be JSON for typed values)
|
||||
value_type: Type hint (string, number, boolean, coordinate)
|
||||
value_type: Type hint (string, number, boolean)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
@@ -19,7 +19,7 @@ class NodeTagCreate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Literal["string", "number", "boolean", "coordinate"] = Field(
|
||||
value_type: Literal["string", "number", "boolean"] = Field(
|
||||
default="string",
|
||||
description="Value type hint",
|
||||
)
|
||||
@@ -32,12 +32,33 @@ class NodeTagUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field(
|
||||
value_type: Optional[Literal["string", "number", "boolean"]] = Field(
|
||||
default=None,
|
||||
description="Value type hint",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagMove(BaseModel):
|
||||
"""Schema for moving a node tag to a different node."""
|
||||
|
||||
new_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Public key of the destination node",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagsCopyResult(BaseModel):
|
||||
"""Schema for bulk copy tags result."""
|
||||
|
||||
copied: int = Field(..., description="Number of tags copied")
|
||||
skipped: int = Field(..., description="Number of tags skipped (already exist)")
|
||||
skipped_keys: list[str] = Field(
|
||||
default_factory=list, description="Keys of skipped tags"
|
||||
)
|
||||
|
||||
|
||||
class NodeTagRead(BaseModel):
|
||||
"""Schema for reading a node tag."""
|
||||
|
||||
@@ -62,6 +83,8 @@ class NodeRead(BaseModel):
|
||||
last_seen: Optional[datetime] = Field(
|
||||
default=None, description="Last activity timestamp"
|
||||
)
|
||||
lat: Optional[float] = Field(default=None, description="GPS latitude coordinate")
|
||||
lon: Optional[float] = Field(default=None, description="GPS longitude coordinate")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Record update timestamp")
|
||||
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
|
||||
|
||||
@@ -100,6 +100,19 @@ def interface() -> None:
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days (RECEIVER mode only)",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
@@ -120,6 +133,8 @@ def run(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the interface component.
|
||||
@@ -162,6 +177,8 @@ def run(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
elif mode_upper == "SENDER":
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
@@ -262,6 +279,19 @@ def run(
|
||||
envvar="MQTT_TLS",
|
||||
help="Enable TLS/SSL for MQTT connection",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup/--no-contact-cleanup",
|
||||
default=True,
|
||||
envvar="CONTACT_CLEANUP_ENABLED",
|
||||
help="Enable/disable automatic removal of stale contacts",
|
||||
)
|
||||
@click.option(
|
||||
"--contact-cleanup-days",
|
||||
type=int,
|
||||
default=7,
|
||||
envvar="CONTACT_CLEANUP_DAYS",
|
||||
help="Remove contacts not advertised for this many days",
|
||||
)
|
||||
def receiver(
|
||||
port: str,
|
||||
baud: int,
|
||||
@@ -274,6 +304,8 @@ def receiver(
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
mqtt_tls: bool,
|
||||
contact_cleanup: bool,
|
||||
contact_cleanup_days: int,
|
||||
) -> None:
|
||||
"""Run interface in RECEIVER mode.
|
||||
|
||||
@@ -293,12 +325,15 @@ def receiver(
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
device_name=device_name,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -211,6 +211,32 @@ class BaseMeshCoreDevice(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's contact database.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if contact was removed successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
|
||||
Args:
|
||||
public_key: The 64-character hex public key of the contact to remove
|
||||
|
||||
Returns:
|
||||
True if request was scheduled successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
@@ -627,6 +653,54 @@ class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
logger.error(f"Failed to schedule get contacts: {e}")
|
||||
return False
|
||||
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the device's contact database.
|
||||
|
||||
Note: This method should only be called before the event loop is running
|
||||
(e.g., during initialization). For calling during event processing,
|
||||
use schedule_remove_contact() instead.
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
self._loop.run_until_complete(_remove_contact())
|
||||
logger.info(f"Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove contact: {e}")
|
||||
return False
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request on the event loop.
|
||||
|
||||
This is safe to call from event handlers while the event loop is running.
|
||||
The request is scheduled as a task on the event loop.
|
||||
|
||||
Returns:
|
||||
True if request was scheduled, False if device not connected
|
||||
"""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
async def _remove_contact() -> None:
|
||||
await self._mc.commands.remove_contact(public_key)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_remove_contact(), self._loop)
|
||||
logger.debug(f"Scheduled removal of contact {public_key[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule remove contact: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -329,6 +329,30 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
"""
|
||||
return self.get_contacts()
|
||||
|
||||
def remove_contact(self, public_key: str) -> bool:
|
||||
"""Remove a contact from the mock device's contact database."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot remove contact: not connected")
|
||||
return False
|
||||
|
||||
# Find and remove the contact from mock_config.nodes
|
||||
for i, node in enumerate(self.mock_config.nodes):
|
||||
if node.public_key == public_key:
|
||||
del self.mock_config.nodes[i]
|
||||
logger.info(f"Mock: Removed contact {public_key[:12]}...")
|
||||
return True
|
||||
|
||||
logger.warning(f"Mock: Contact {public_key[:12]}... not found")
|
||||
return True # Return True even if not found (idempotent)
|
||||
|
||||
def schedule_remove_contact(self, public_key: str) -> bool:
|
||||
"""Schedule a remove_contact request.
|
||||
|
||||
For the mock device, this is the same as remove_contact() since we
|
||||
don't have a real async event loop.
|
||||
"""
|
||||
return self.remove_contact(public_key)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
|
||||
@@ -20,6 +20,9 @@ from meshcore_hub.interface.device import (
|
||||
create_device,
|
||||
)
|
||||
|
||||
# Default contact cleanup settings
|
||||
DEFAULT_CONTACT_CLEANUP_DAYS = 7
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +37,8 @@ class Receiver:
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
device_name: Optional[str] = None,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
):
|
||||
"""Initialize receiver.
|
||||
|
||||
@@ -41,10 +46,14 @@ class Receiver:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
device_name: Optional device/node name to set on startup
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self.device_name = device_name
|
||||
self.contact_cleanup_enabled = contact_cleanup_enabled
|
||||
self.contact_cleanup_days = contact_cleanup_days
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
self._device_connected = False
|
||||
@@ -167,6 +176,8 @@ class Receiver:
|
||||
|
||||
The device returns contacts as a dict keyed by public_key.
|
||||
We split this into individual 'contact' events for cleaner processing.
|
||||
Stale contacts (not advertised for > contact_cleanup_days) are removed
|
||||
from the device and not published.
|
||||
|
||||
Args:
|
||||
payload: Dict of contacts keyed by public_key
|
||||
@@ -188,22 +199,54 @@ class Receiver:
|
||||
return
|
||||
|
||||
device_key = self.device.public_key # Capture for type narrowing
|
||||
count = 0
|
||||
current_time = int(time.time())
|
||||
stale_threshold = current_time - (self.contact_cleanup_days * 24 * 60 * 60)
|
||||
|
||||
published_count = 0
|
||||
removed_count = 0
|
||||
|
||||
for contact in contacts:
|
||||
if not isinstance(contact, dict):
|
||||
continue
|
||||
|
||||
public_key = contact.get("public_key")
|
||||
if not public_key:
|
||||
continue
|
||||
|
||||
# Check if contact is stale based on last_advert timestamp
|
||||
# Only check if cleanup is enabled and last_advert exists
|
||||
if self.contact_cleanup_enabled:
|
||||
last_advert = contact.get("last_advert")
|
||||
if last_advert is not None and last_advert > 0:
|
||||
if last_advert < stale_threshold:
|
||||
# Contact is stale - remove from device
|
||||
adv_name = contact.get("adv_name", contact.get("name", ""))
|
||||
logger.info(
|
||||
f"Removing stale contact {public_key[:12]}... "
|
||||
f"({adv_name}) - last advertised "
|
||||
f"{(current_time - last_advert) // 86400} days ago"
|
||||
)
|
||||
self.device.schedule_remove_contact(public_key)
|
||||
removed_count += 1
|
||||
continue # Don't publish stale contacts
|
||||
|
||||
try:
|
||||
self.mqtt.publish_event(
|
||||
device_key,
|
||||
"contact", # Use singular 'contact' for individual events
|
||||
contact,
|
||||
)
|
||||
count += 1
|
||||
published_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish contact event: {e}")
|
||||
|
||||
logger.info(f"Published {count} contact events to MQTT")
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
f"Contact sync: published {published_count}, "
|
||||
f"removed {removed_count} stale contacts"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Published {published_count} contact events to MQTT")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the receiver."""
|
||||
@@ -306,6 +349,8 @@ def create_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> Receiver:
|
||||
"""Create a configured receiver instance.
|
||||
|
||||
@@ -321,6 +366,8 @@ def create_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
|
||||
Returns:
|
||||
Configured Receiver instance
|
||||
@@ -345,7 +392,13 @@ def create_receiver(
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Receiver(device, mqtt_client, device_name=device_name)
|
||||
return Receiver(
|
||||
device,
|
||||
mqtt_client,
|
||||
device_name=device_name,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
|
||||
def run_receiver(
|
||||
@@ -360,6 +413,8 @@ def run_receiver(
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
mqtt_tls: bool = False,
|
||||
contact_cleanup_enabled: bool = True,
|
||||
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
|
||||
) -> None:
|
||||
"""Run the receiver (blocking).
|
||||
|
||||
@@ -377,6 +432,8 @@ def run_receiver(
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
mqtt_tls: Enable TLS/SSL for MQTT connection
|
||||
contact_cleanup_enabled: Whether to remove stale contacts from device
|
||||
contact_cleanup_days: Remove contacts not advertised for this many days
|
||||
"""
|
||||
receiver = create_receiver(
|
||||
port=port,
|
||||
@@ -390,6 +447,8 @@ def run_receiver(
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
mqtt_tls=mqtt_tls,
|
||||
contact_cleanup_enabled=contact_cleanup_enabled,
|
||||
contact_cleanup_days=contact_cleanup_days,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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 uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.schemas import RadioConfig
|
||||
from meshcore_hub.web.pages import PageLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +27,75 @@ TEMPLATES_DIR = PACKAGE_DIR / "templates"
|
||||
STATIC_DIR = PACKAGE_DIR / "static"
|
||||
|
||||
|
||||
def _create_timezone_filters(tz_name: str) -> dict:
|
||||
"""Create Jinja2 filters for timezone-aware date formatting.
|
||||
|
||||
Args:
|
||||
tz_name: IANA timezone name (e.g., "America/New_York", "Europe/London")
|
||||
|
||||
Returns:
|
||||
Dict of filter name -> filter function
|
||||
"""
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(f"Invalid timezone '{tz_name}', falling back to UTC")
|
||||
tz = ZoneInfo("UTC")
|
||||
|
||||
def format_datetime(
|
||||
value: str | datetime | None,
|
||||
fmt: str = "%Y-%m-%d %H:%M:%S",
|
||||
) -> str:
|
||||
"""Format a UTC datetime string or object to the configured timezone.
|
||||
|
||||
Args:
|
||||
value: ISO 8601 UTC datetime string or datetime object
|
||||
fmt: strftime format string
|
||||
|
||||
Returns:
|
||||
Formatted datetime string in configured timezone
|
||||
"""
|
||||
if value is None:
|
||||
return "-"
|
||||
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
# Parse ISO 8601 string (assume UTC if no timezone)
|
||||
value = value.replace("Z", "+00:00")
|
||||
if "+" not in value and "-" not in value[10:]:
|
||||
# No timezone info, assume UTC
|
||||
dt = datetime.fromisoformat(value).replace(tzinfo=ZoneInfo("UTC"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(value)
|
||||
else:
|
||||
dt = value
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
# Convert to target timezone
|
||||
local_dt = dt.astimezone(tz)
|
||||
return local_dt.strftime(fmt)
|
||||
except Exception:
|
||||
# Fallback to original value if parsing fails
|
||||
return str(value)[:19].replace("T", " ") if value else "-"
|
||||
|
||||
def format_time(value: str | datetime | None, fmt: str = "%H:%M:%S") -> str:
|
||||
"""Format just the time portion in the configured timezone."""
|
||||
return format_datetime(value, fmt)
|
||||
|
||||
def format_date(value: str | datetime | None, fmt: str = "%Y-%m-%d") -> str:
|
||||
"""Format just the date portion in the configured timezone."""
|
||||
return format_datetime(value, fmt)
|
||||
|
||||
return {
|
||||
"localtime": format_datetime,
|
||||
"localtime_short": lambda v: format_datetime(v, "%Y-%m-%d %H:%M"),
|
||||
"localdate": format_date,
|
||||
"localtimeonly": format_time,
|
||||
"localtimeonly_short": lambda v: format_time(v, "%H:%M"),
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
@@ -50,6 +125,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
admin_enabled: bool | None = None,
|
||||
network_name: str | None = None,
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
@@ -57,6 +133,7 @@ def create_app(
|
||||
network_contact_email: str | None = None,
|
||||
network_contact_discord: str | None = None,
|
||||
network_contact_github: str | None = None,
|
||||
network_contact_youtube: str | None = None,
|
||||
network_welcome_text: str | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the web dashboard application.
|
||||
@@ -67,6 +144,7 @@ def create_app(
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
admin_enabled: Enable admin interface at /a/
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
@@ -74,6 +152,7 @@ def create_app(
|
||||
network_contact_email: Contact email address
|
||||
network_contact_discord: Discord invite/server info
|
||||
network_contact_github: GitHub repository URL
|
||||
network_contact_youtube: YouTube channel URL
|
||||
network_welcome_text: Welcome text for homepage
|
||||
|
||||
Returns:
|
||||
@@ -93,9 +172,16 @@ def create_app(
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
|
||||
# This ensures url_for() generates correct HTTPS URLs behind a reverse proxy
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
# Store configuration in app state (use args if provided, else settings)
|
||||
app.state.api_url = api_url or settings.api_base_url
|
||||
app.state.api_key = api_key or settings.api_key
|
||||
app.state.admin_enabled = (
|
||||
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
|
||||
)
|
||||
app.state.network_name = network_name or settings.network_name
|
||||
app.state.network_city = network_city or settings.network_city
|
||||
app.state.network_country = network_country or settings.network_country
|
||||
@@ -111,18 +197,55 @@ def create_app(
|
||||
app.state.network_contact_github = (
|
||||
network_contact_github or settings.network_contact_github
|
||||
)
|
||||
app.state.network_contact_youtube = (
|
||||
network_contact_youtube or settings.network_contact_youtube
|
||||
)
|
||||
app.state.network_welcome_text = (
|
||||
network_welcome_text or settings.network_welcome_text
|
||||
)
|
||||
|
||||
# Set up templates
|
||||
# Set up templates with whitespace control
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.trim_blocks = True # Remove first newline after block tags
|
||||
templates.env.lstrip_blocks = True # Remove leading whitespace before block tags
|
||||
|
||||
# Register timezone-aware date formatting filters
|
||||
app.state.timezone = settings.tz
|
||||
tz_filters = _create_timezone_filters(settings.tz)
|
||||
for name, func in tz_filters.items():
|
||||
templates.env.filters[name] = func
|
||||
|
||||
# Compute timezone abbreviation (e.g., "GMT", "EST", "PST")
|
||||
try:
|
||||
tz = ZoneInfo(settings.tz)
|
||||
app.state.timezone_abbr = datetime.now(tz).strftime("%Z")
|
||||
except Exception:
|
||||
app.state.timezone_abbr = "UTC"
|
||||
|
||||
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
|
||||
|
||||
# Check for custom logo and store media path
|
||||
media_home = Path(settings.effective_media_home)
|
||||
custom_logo_path = media_home / "images" / "logo.svg"
|
||||
if custom_logo_path.exists():
|
||||
app.state.logo_url = "/media/images/logo.svg"
|
||||
logger.info(f"Using custom logo from {custom_logo_path}")
|
||||
else:
|
||||
app.state.logo_url = "/static/img/logo.svg"
|
||||
|
||||
# Mount static files
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Mount custom media files if directory exists
|
||||
if media_home.exists() and media_home.is_dir():
|
||||
app.mount("/media", StaticFiles(directory=str(media_home)), name="media")
|
||||
|
||||
# Include routers
|
||||
from meshcore_hub.web.routes import web_router
|
||||
|
||||
@@ -145,6 +268,109 @@ def create_app(
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "api": str(e)}
|
||||
|
||||
def _get_https_base_url(request: Request) -> str:
|
||||
"""Get base URL, ensuring HTTPS is used for public-facing URLs."""
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
# Ensure HTTPS for sitemaps and robots.txt (SEO requires canonical URLs)
|
||||
if base_url.startswith("http://"):
|
||||
base_url = "https://" + base_url[7:]
|
||||
return base_url
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots_txt(request: Request) -> str:
|
||||
"""Serve robots.txt to control search engine crawling."""
|
||||
base_url = _get_https_base_url(request)
|
||||
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml(request: Request) -> Response:
|
||||
"""Generate dynamic sitemap including all node pages."""
|
||||
base_url = _get_https_base_url(request)
|
||||
|
||||
# Static pages
|
||||
static_pages = [
|
||||
("", "daily", "1.0"),
|
||||
("/dashboard", "hourly", "0.9"),
|
||||
("/nodes", "hourly", "0.9"),
|
||||
("/advertisements", "hourly", "0.8"),
|
||||
("/messages", "hourly", "0.8"),
|
||||
("/map", "daily", "0.7"),
|
||||
("/members", "weekly", "0.6"),
|
||||
]
|
||||
|
||||
urls = []
|
||||
for path, changefreq, priority in static_pages:
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{path}</loc>\n"
|
||||
f" <changefreq>{changefreq}</changefreq>\n"
|
||||
f" <priority>{priority}</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
# Fetch infrastructure nodes for dynamic pages
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500, "role": "infra"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
nodes = response.json().get("items", [])
|
||||
for node in nodes:
|
||||
public_key = node.get("public_key")
|
||||
if public_key:
|
||||
# Use 8-char prefix (route handles redirect to full key)
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
|
||||
f" <changefreq>daily</changefreq>\n"
|
||||
f" <priority>0.5</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch nodes for sitemap: {response.status_code}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
|
||||
|
||||
# Add custom pages to sitemap
|
||||
page_loader = request.app.state.page_loader
|
||||
for page in page_loader.get_menu_pages():
|
||||
urls.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base_url}{page.url}</loc>\n"
|
||||
f" <changefreq>weekly</changefreq>\n"
|
||||
f" <priority>0.6</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>"
|
||||
)
|
||||
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(
|
||||
request: Request, exc: StarletteHTTPException
|
||||
) -> HTMLResponse:
|
||||
"""Handle HTTP exceptions with custom error pages."""
|
||||
if exc.status_code == 404:
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["detail"] = exc.detail if exc.detail != "Not Found" else None
|
||||
return templates.TemplateResponse(
|
||||
"errors/404.html", context, status_code=404
|
||||
)
|
||||
# For other errors, return a simple response
|
||||
return HTMLResponse(
|
||||
content=f"<h1>{exc.status_code}</h1><p>{exc.detail}</p>",
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -161,6 +387,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,
|
||||
@@ -169,6 +399,11 @@ def get_network_context(request: Request) -> dict:
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_contact_youtube": request.app.state.network_contact_youtube,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"admin_enabled": request.app.state.admin_enabled,
|
||||
"custom_pages": custom_pages,
|
||||
"logo_url": request.app.state.logo_url,
|
||||
"version": __version__,
|
||||
"timezone": request.app.state.timezone_abbr,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ import click
|
||||
envvar="NETWORK_CONTACT_GITHUB",
|
||||
help="GitHub repository URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-contact-youtube",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CONTACT_YOUTUBE",
|
||||
help="YouTube channel URL",
|
||||
)
|
||||
@click.option(
|
||||
"--network-welcome-text",
|
||||
type=str,
|
||||
@@ -116,6 +123,7 @@ def web(
|
||||
network_contact_email: str | None,
|
||||
network_contact_discord: str | None,
|
||||
network_contact_github: str | None,
|
||||
network_contact_youtube: str | None,
|
||||
network_welcome_text: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
@@ -201,6 +209,7 @@ def web(
|
||||
network_contact_email=network_contact_email,
|
||||
network_contact_discord=network_contact_discord,
|
||||
network_contact_github=network_contact_github,
|
||||
network_contact_youtube=network_contact_youtube,
|
||||
network_welcome_text=network_welcome_text,
|
||||
)
|
||||
|
||||
|
||||
119
src/meshcore_hub/web/pages.py
Normal file
119
src/meshcore_hub/web/pages.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomPage:
|
||||
"""Represents a custom markdown page."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
menu_order: int
|
||||
content_html: str
|
||||
file_path: str
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Get the URL path for this page."""
|
||||
return f"/pages/{self.slug}"
|
||||
|
||||
|
||||
class PageLoader:
|
||||
"""Loads and manages custom markdown pages from a directory."""
|
||||
|
||||
def __init__(self, pages_dir: str) -> None:
|
||||
"""Initialize the page loader.
|
||||
|
||||
Args:
|
||||
pages_dir: Path to the directory containing markdown pages.
|
||||
"""
|
||||
self.pages_dir = Path(pages_dir)
|
||||
self._pages: dict[str, CustomPage] = {}
|
||||
self._md = markdown.Markdown(
|
||||
extensions=["tables", "fenced_code", "toc"],
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
def load_pages(self) -> None:
|
||||
"""Load all markdown pages from the pages directory."""
|
||||
self._pages.clear()
|
||||
|
||||
if not self.pages_dir.exists():
|
||||
logger.debug(f"Pages directory does not exist: {self.pages_dir}")
|
||||
return
|
||||
|
||||
if not self.pages_dir.is_dir():
|
||||
logger.warning(f"Pages path is not a directory: {self.pages_dir}")
|
||||
return
|
||||
|
||||
for md_file in self.pages_dir.glob("*.md"):
|
||||
try:
|
||||
page = self._load_page(md_file)
|
||||
if page:
|
||||
self._pages[page.slug] = page
|
||||
logger.info(f"Loaded custom page: {page.slug} ({md_file.name})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load page {md_file}: {e}")
|
||||
|
||||
logger.info(f"Loaded {len(self._pages)} custom page(s)")
|
||||
|
||||
def _load_page(self, file_path: Path) -> Optional[CustomPage]:
|
||||
"""Load a single markdown page.
|
||||
|
||||
Args:
|
||||
file_path: Path to the markdown file.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if loading failed.
|
||||
"""
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
post = frontmatter.loads(content)
|
||||
|
||||
# Extract frontmatter fields
|
||||
slug = post.get("slug", file_path.stem)
|
||||
title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
|
||||
menu_order = post.get("menu_order", 100)
|
||||
|
||||
# Convert markdown to HTML
|
||||
self._md.reset()
|
||||
content_html = self._md.convert(post.content)
|
||||
|
||||
return CustomPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
menu_order=menu_order,
|
||||
content_html=content_html,
|
||||
file_path=str(file_path),
|
||||
)
|
||||
|
||||
def get_page(self, slug: str) -> Optional[CustomPage]:
|
||||
"""Get a page by its slug.
|
||||
|
||||
Args:
|
||||
slug: The page slug.
|
||||
|
||||
Returns:
|
||||
CustomPage instance or None if not found.
|
||||
"""
|
||||
return self._pages.get(slug)
|
||||
|
||||
def get_menu_pages(self) -> list[CustomPage]:
|
||||
"""Get all pages sorted by menu_order for navigation.
|
||||
|
||||
Returns:
|
||||
List of CustomPage instances sorted by menu_order.
|
||||
"""
|
||||
return sorted(self._pages.values(), key=lambda p: (p.menu_order, p.title))
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload all pages from disk."""
|
||||
self.load_pages()
|
||||
@@ -3,23 +3,27 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.web.routes.home import router as home_router
|
||||
from meshcore_hub.web.routes.network import router as network_router
|
||||
from meshcore_hub.web.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.web.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
from meshcore_hub.web.routes.pages import router as pages_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(network_router)
|
||||
web_router.include_router(dashboard_router)
|
||||
web_router.include_router(nodes_router)
|
||||
web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
web_router.include_router(pages_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
|
||||
591
src/meshcore_hub/web/routes/admin.py
Normal file
591
src/meshcore_hub/web/routes/admin.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""Admin page routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from httpx import Response
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
|
||||
def _build_redirect_url(
|
||||
public_key: str,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL with optional message/error."""
|
||||
params: dict[str, str] = {"public_key": public_key}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
return f"/a/node-tags?{urlencode(params)}"
|
||||
|
||||
|
||||
def _get_error_detail(response: Response) -> str:
|
||||
"""Safely extract error detail from response JSON."""
|
||||
try:
|
||||
data: Any = response.json()
|
||||
detail: str = data.get("detail", "Unknown error")
|
||||
return detail
|
||||
except Exception:
|
||||
return "Unknown error"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/a", tags=["admin"])
|
||||
|
||||
|
||||
def _check_admin_enabled(request: Request) -> None:
|
||||
"""Check if admin interface is enabled, raise 404 if not."""
|
||||
if not getattr(request.app.state, "admin_enabled", False):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
def _get_auth_context(request: Request) -> dict:
|
||||
"""Extract OAuth2Proxy authentication headers."""
|
||||
return {
|
||||
"auth_user": request.headers.get("X-Forwarded-User"),
|
||||
"auth_groups": request.headers.get("X-Forwarded-Groups"),
|
||||
"auth_email": request.headers.get("X-Forwarded-Email"),
|
||||
"auth_username": request.headers.get("X-Forwarded-Preferred-Username"),
|
||||
}
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""Check if user is authenticated via OAuth2Proxy headers."""
|
||||
return bool(
|
||||
request.headers.get("X-Forwarded-User")
|
||||
or request.headers.get("X-Forwarded-Email")
|
||||
)
|
||||
|
||||
|
||||
def _require_auth(request: Request) -> None:
|
||||
"""Require authentication, raise 403 if not authenticated."""
|
||||
if not _is_authenticated(request):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home(request: Request) -> HTMLResponse:
|
||||
"""Render the admin page with OAuth2Proxy user info."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("admin/index.html", context)
|
||||
|
||||
|
||||
@router.get("/node-tags", response_class=HTMLResponse)
|
||||
async def admin_node_tags(
|
||||
request: Request,
|
||||
public_key: Optional[str] = Query(None),
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing node tags."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all nodes for dropdown
|
||||
nodes = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes",
|
||||
params={"limit": 100},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
# Sort nodes alphabetically by name (unnamed nodes at the end)
|
||||
nodes.sort(
|
||||
key=lambda n: (n.get("name") is None, (n.get("name") or "").lower())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch nodes: %s", e)
|
||||
context["error"] = "Failed to fetch nodes"
|
||||
|
||||
context["nodes"] = nodes
|
||||
context["selected_public_key"] = public_key
|
||||
|
||||
# Fetch tags for selected node
|
||||
tags = []
|
||||
selected_node = None
|
||||
if public_key:
|
||||
# Find the selected node in the list
|
||||
for node in nodes:
|
||||
if node.get("public_key") == public_key:
|
||||
selected_node = node
|
||||
break
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
tags = response.json()
|
||||
elif response.status_code == 404:
|
||||
context["error"] = "Node not found"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch tags: %s", e)
|
||||
context["error"] = "Failed to fetch tags"
|
||||
|
||||
context["tags"] = tags
|
||||
context["selected_node"] = selected_node
|
||||
|
||||
return templates.TemplateResponse("admin/node_tags.html", context)
|
||||
|
||||
|
||||
@router.post("/node-tags", response_class=RedirectResponse)
|
||||
async def admin_create_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
json={
|
||||
"key": key,
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(public_key, error="Node not found")
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to create tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/update", response_class=RedirectResponse)
|
||||
async def admin_update_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
value_type: str = Form("string"),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
json={
|
||||
"value": value or None,
|
||||
"value_type": value_type,
|
||||
},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to update tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/move", response_class=RedirectResponse)
|
||||
async def admin_move_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
new_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Move a node tag to a different node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}/move",
|
||||
json={"new_public_key": new_public_key},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
# Redirect to the destination node after successful move
|
||||
redirect_url = _build_redirect_url(
|
||||
new_public_key, message=f"Tag '{key}' moved successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
# Stay on source node if not found
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' already exists on destination node"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to move tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to move tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_node_tag(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a node tag."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags/{key}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, message=f"Tag '{key}' deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=f"Tag '{key}' not found"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tag: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tag")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/copy-all", response_class=RedirectResponse)
|
||||
async def admin_copy_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
dest_public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Copy all tags from one node to another."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.post(
|
||||
f"/api/v1/nodes/{public_key}/tags/copy-to/{dest_public_key}",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
copied = data.get("copied", 0)
|
||||
skipped = data.get("skipped", 0)
|
||||
if skipped > 0:
|
||||
message = f"Copied {copied} tag(s), skipped {skipped} existing"
|
||||
else:
|
||||
message = f"Copied {copied} tag(s) successfully"
|
||||
# Redirect to destination node to show copied tags
|
||||
redirect_url = _build_redirect_url(dest_public_key, message=message)
|
||||
elif response.status_code == 400:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to copy tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to copy tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/node-tags/delete-all", response_class=RedirectResponse)
|
||||
async def admin_delete_all_tags(
|
||||
request: Request,
|
||||
public_key: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete all tags from a node."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/nodes/{public_key}/tags",
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
deleted = data.get("deleted", 0)
|
||||
message = f"Deleted {deleted} tag(s) successfully"
|
||||
redirect_url = _build_redirect_url(public_key, message=message)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_redirect_url(
|
||||
public_key, error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete tags: %s", e)
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
def _build_members_redirect_url(
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL for members page with optional message/error."""
|
||||
params: dict[str, str] = {}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
if params:
|
||||
return f"/a/members?{urlencode(params)}"
|
||||
return "/a/members"
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def admin_members(
|
||||
request: Request,
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing members."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all members
|
||||
members = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members",
|
||||
params={"limit": 500},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
# Sort members alphabetically by name
|
||||
members.sort(key=lambda m: m.get("name", "").lower())
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch members: %s", e)
|
||||
context["error"] = "Failed to fetch members"
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("admin/members.html", context)
|
||||
|
||||
|
||||
@router.post("/members", response_class=RedirectResponse)
|
||||
async def admin_create_member(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
member_id: str = Form(...),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build request payload
|
||||
payload = {
|
||||
"name": name,
|
||||
"member_id": member_id,
|
||||
}
|
||||
if callsign:
|
||||
payload["callsign"] = callsign
|
||||
if role:
|
||||
payload["role"] = role
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if contact:
|
||||
payload["contact"] = contact
|
||||
|
||||
response = await request.app.state.http_client.post(
|
||||
"/api/v1/members",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message=f"Member '{name}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to create member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/update", response_class=RedirectResponse)
|
||||
async def admin_update_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
name: Optional[str] = Form(None),
|
||||
member_id: Optional[str] = Form(None),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build update payload (only include non-None fields)
|
||||
payload: dict[str, str | None] = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
if member_id is not None:
|
||||
payload["member_id"] = member_id
|
||||
if callsign is not None:
|
||||
payload["callsign"] = callsign if callsign else None
|
||||
if role is not None:
|
||||
payload["role"] = role if role else None
|
||||
if description is not None:
|
||||
payload["description"] = description if description else None
|
||||
if contact is not None:
|
||||
payload["contact"] = contact if contact else None
|
||||
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/members/{id}",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to update member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/members/{id}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to delete member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
@@ -15,6 +15,8 @@ router = APIRouter()
|
||||
async def advertisements_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
public_key: str | None = Query(None, description="Filter by node public key"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -30,12 +32,41 @@ async def advertisements_list(
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
if public_key:
|
||||
params["public_key"] = public_key
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
total = 0
|
||||
members = []
|
||||
nodes = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
# Fetch nodes for dropdown
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if nodes_response.status_code == 200:
|
||||
nodes = nodes_response.json().get("items", [])
|
||||
|
||||
# Sort nodes alphabetically by display name
|
||||
def get_node_display_name(node: dict) -> str:
|
||||
for tag in node.get("tags") or []:
|
||||
if tag.get("key") == "name":
|
||||
return str(tag.get("value", "")).lower()
|
||||
return str(node.get("name") or node.get("public_key", "")).lower()
|
||||
|
||||
nodes.sort(key=get_node_display_name)
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params=params
|
||||
)
|
||||
@@ -58,6 +89,10 @@ async def advertisements_list(
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"member_id": member_id or "",
|
||||
"public_key": public_key or "",
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Network overview page route."""
|
||||
"""Dashboard page route."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/network", response_class=HTMLResponse)
|
||||
async def network_overview(request: Request) -> HTMLResponse:
|
||||
"""Render the network overview page."""
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Render the dashboard page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
@@ -76,4 +76,4 @@ async def network_overview(request: Request) -> HTMLResponse:
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
context["node_count_json"] = json.dumps(node_count)
|
||||
|
||||
return templates.TemplateResponse("network.html", context)
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
@@ -29,8 +29,9 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"advertisements_24h": 0,
|
||||
}
|
||||
|
||||
# Fetch activity data for chart
|
||||
activity = {"days": 7, "data": []}
|
||||
# Fetch activity data for charts
|
||||
advert_activity = {"days": 7, "data": []}
|
||||
message_activity = {"days": 7, "data": []}
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
|
||||
@@ -45,12 +46,22 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"/api/v1/dashboard/activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
activity = response.json()
|
||||
advert_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch activity from API: {e}")
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/dashboard/message-activity", params={"days": 7}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
message_activity = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch message activity from API: {e}")
|
||||
|
||||
context["stats"] = stats
|
||||
# Pass activity data as JSON string for the chart
|
||||
context["activity_json"] = json.dumps(activity)
|
||||
# Pass activity data as JSON strings for the chart
|
||||
context["advert_activity_json"] = json.dumps(advert_activity)
|
||||
context["message_activity_json"] = json.dumps(message_activity)
|
||||
|
||||
return templates.TemplateResponse("home.html", context)
|
||||
|
||||
@@ -30,28 +30,27 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"""
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_key: dict[str, dict[str, Any]] = {}
|
||||
members_by_id: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by public_key
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
# Only include members with public_key (required for node ownership)
|
||||
if member.get("public_key"):
|
||||
member_info = {
|
||||
"public_key": member.get("public_key"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
members_by_key[member["public_key"]] = member_info
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch members: status {members_response.status_code}"
|
||||
@@ -66,57 +65,68 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
nodes = data.get("items", [])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
# Filter nodes with location tags
|
||||
# Filter nodes with location (from tags or model)
|
||||
for node in nodes:
|
||||
tags = node.get("tags", [])
|
||||
lat = None
|
||||
lon = None
|
||||
tag_lat = None
|
||||
tag_lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
if key == "lat":
|
||||
try:
|
||||
lat = float(tag.get("value"))
|
||||
tag_lat = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "lon":
|
||||
try:
|
||||
lon = float(tag.get("value"))
|
||||
tag_lon = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif key == "friendly_name":
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
nodes_with_coords += 1
|
||||
# Use friendly_name, then node name, then public key prefix
|
||||
display_name = (
|
||||
friendly_name
|
||||
or node.get("name")
|
||||
or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
# Use tag coordinates if set, otherwise fall back to model coordinates
|
||||
lat = tag_lat if tag_lat is not None else node.get("lat")
|
||||
lon = tag_lon if tag_lon is not None else node.get("lon")
|
||||
|
||||
# Find owner member if exists
|
||||
owner = members_by_key.get(public_key)
|
||||
# Skip nodes without coordinates or with (0, 0) which is likely unset
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
continue
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": display_name,
|
||||
"adv_type": node.get("adv_type"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
nodes_with_coords += 1
|
||||
# Use friendly_name, then node name, then public key prefix
|
||||
display_name = (
|
||||
friendly_name or node.get("name") or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
|
||||
# Find owner member by member_id tag
|
||||
owner = members_by_id.get(node_member_id) if node_member_id else None
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": display_name,
|
||||
"adv_type": node.get("adv_type"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
else:
|
||||
error = f"API returned status {response.status_code}"
|
||||
logger.warning(f"Failed to fetch nodes: {error}")
|
||||
@@ -125,11 +135,17 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
error = str(e)
|
||||
logger.warning(f"Failed to fetch nodes for map: {e}")
|
||||
|
||||
# Calculate infrastructure node stats
|
||||
infra_nodes = [n for n in nodes_with_location if n.get("is_infra")]
|
||||
infra_count = len(infra_nodes)
|
||||
|
||||
logger.info(
|
||||
f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates"
|
||||
f"Map data: {total_nodes} total nodes, "
|
||||
f"{nodes_with_coords} with coordinates, "
|
||||
f"{infra_count} infrastructure"
|
||||
)
|
||||
|
||||
# Calculate center from nodes, or use default (0, 0)
|
||||
# Calculate center from all nodes, or use default (0, 0)
|
||||
center_lat = 0.0
|
||||
center_lon = 0.0
|
||||
if nodes_with_location:
|
||||
@@ -140,6 +156,14 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
nodes_with_location
|
||||
)
|
||||
|
||||
# Calculate separate center for infrastructure nodes
|
||||
infra_center: dict[str, float] | None = None
|
||||
if infra_nodes:
|
||||
infra_center = {
|
||||
"lat": sum(n["lat"] for n in infra_nodes) / len(infra_nodes),
|
||||
"lon": sum(n["lon"] for n in infra_nodes) / len(infra_nodes),
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"nodes": nodes_with_location,
|
||||
@@ -148,9 +172,11 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"lat": center_lat,
|
||||
"lon": center_lon,
|
||||
},
|
||||
"infra_center": infra_center,
|
||||
"debug": {
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_with_coords": nodes_with_coords,
|
||||
"infra_nodes": infra_count,
|
||||
"error": error,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ router = APIRouter()
|
||||
async def messages_list(
|
||||
request: Request,
|
||||
message_type: str | None = Query(None, description="Filter by message type"),
|
||||
channel_idx: str | None = Query(None, description="Filter by channel"),
|
||||
search: str | None = Query(None, description="Search in message text"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
@@ -28,20 +27,10 @@ async def messages_list(
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Parse channel_idx, treating empty string as None
|
||||
channel_idx_int: int | None = None
|
||||
if channel_idx and channel_idx.strip():
|
||||
try:
|
||||
channel_idx_int = int(channel_idx)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid channel_idx value: {channel_idx}")
|
||||
|
||||
# Build query params
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if message_type:
|
||||
params["message_type"] = message_type
|
||||
if channel_idx_int is not None:
|
||||
params["channel_idx"] = channel_idx_int
|
||||
|
||||
# Fetch messages from API
|
||||
messages = []
|
||||
@@ -70,7 +59,6 @@ async def messages_list(
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"message_type": message_type or "",
|
||||
"channel_idx": channel_idx_int,
|
||||
"search": search or "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
@@ -16,6 +16,7 @@ async def nodes_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
adv_type: str | None = Query(None, description="Filter by node type"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -33,12 +34,22 @@ async def nodes_list(
|
||||
params["search"] = search
|
||||
if adv_type:
|
||||
params["adv_type"] = adv_type
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
members = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params=params
|
||||
)
|
||||
@@ -62,31 +73,53 @@ async def nodes_list(
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"adv_type": adv_type or "",
|
||||
"member_id": member_id or "",
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
|
||||
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
"""Render the node detail page."""
|
||||
@router.get("/n/{prefix}")
|
||||
async def node_short_link(prefix: str) -> RedirectResponse:
|
||||
"""Redirect short link to nodes page."""
|
||||
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_model=None)
|
||||
async def node_detail(
|
||||
request: Request, public_key: str
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
"""Render the node detail page.
|
||||
|
||||
If the key is not a full 64-character public key, uses the prefix API
|
||||
to resolve it and redirects to the canonical URL.
|
||||
"""
|
||||
# If not a full public key, resolve via prefix API and redirect
|
||||
if len(public_key) != 64:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/prefix/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
node = None
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
try:
|
||||
# Fetch node details
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
# Fetch node details (exact match)
|
||||
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node = response.json()
|
||||
|
||||
try:
|
||||
# Fetch recent advertisements for this node
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params={"public_key": public_key, "limit": 10}
|
||||
@@ -105,12 +138,18 @@ async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
logger.warning(f"Failed to fetch node details from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# Check if admin editing is available
|
||||
admin_enabled = getattr(request.app.state, "admin_enabled", False)
|
||||
auth_user = request.headers.get("X-Forwarded-User")
|
||||
|
||||
context.update(
|
||||
{
|
||||
"node": node,
|
||||
"advertisements": advertisements,
|
||||
"telemetry": telemetry,
|
||||
"public_key": public_key,
|
||||
"admin_enabled": admin_enabled,
|
||||
"is_authenticated": bool(auth_user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
36
src/meshcore_hub/web/routes/pages.py
Normal file
36
src/meshcore_hub/web/routes/pages.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Custom pages route for MeshCore Hub Web Dashboard."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
router = APIRouter(tags=["Pages"])
|
||||
|
||||
|
||||
@router.get("/pages/{slug}", response_class=HTMLResponse)
|
||||
async def custom_page(request: Request, slug: str) -> HTMLResponse:
|
||||
"""Render a custom markdown page.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
slug: The page slug from the URL.
|
||||
|
||||
Returns:
|
||||
Rendered HTML page.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if page not found.
|
||||
"""
|
||||
page_loader = request.app.state.page_loader
|
||||
page = page_loader.get_page(slug)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context["page"] = page
|
||||
|
||||
return templates.TemplateResponse("page.html", context)
|
||||
365
src/meshcore_hub/web/static/css/app.css
Normal file
365
src/meshcore_hub/web/static/css/app.css
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* MeshCore Hub - Custom Application Styles
|
||||
*
|
||||
* This file contains all custom CSS that extends the Tailwind/DaisyUI framework.
|
||||
* Organized in sections:
|
||||
* - Scrollbar styling
|
||||
* - Table styling
|
||||
* - Text utilities
|
||||
* - Prose (markdown content) styling
|
||||
* - View Transitions API
|
||||
* - Card animations
|
||||
* - Leaflet map theming
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Scrollbar Styling
|
||||
========================================================================== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--b2));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.5);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Table Styling
|
||||
========================================================================== */
|
||||
|
||||
.table-compact td,
|
||||
.table-compact th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Text Utilities
|
||||
========================================================================== */
|
||||
|
||||
.truncate-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Prose Styling (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;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
View Transitions API
|
||||
========================================================================== */
|
||||
|
||||
/* Named transition elements */
|
||||
.navbar {
|
||||
view-transition-name: navbar;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
main {
|
||||
view-transition-name: main-content;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
footer {
|
||||
view-transition-name: footer;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Subtle slide + fade for main content */
|
||||
::view-transition-old(main-content) {
|
||||
animation: vt-fade-out 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(main-content) {
|
||||
animation: vt-slide-up 250ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Keep navbar and footer stable */
|
||||
::view-transition-old(navbar),
|
||||
::view-transition-new(navbar),
|
||||
::view-transition-old(footer),
|
||||
::view-transition-new(footer) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Subtle crossfade for background */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 150ms;
|
||||
}
|
||||
|
||||
@keyframes vt-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Card Entrance Animations
|
||||
========================================================================== */
|
||||
|
||||
/* Only for stat cards with .animate-stagger class */
|
||||
.animate-stagger > .card,
|
||||
.animate-stagger > .stat {
|
||||
animation: card-fade-in 300ms ease-out backwards;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(5) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animate-stagger > :nth-child(6) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
@keyframes card-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced Motion Preferences
|
||||
========================================================================== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
::view-transition-old(main-content),
|
||||
::view-transition-new(main-content),
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Leaflet Map Theming (Dark Mode)
|
||||
========================================================================== */
|
||||
|
||||
/* Popup styling */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
|
||||
/* Map container defaults */
|
||||
#map,
|
||||
#node-map {
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
|
||||
#map {
|
||||
height: calc(100vh - 350px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
#node-map {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Map label visibility */
|
||||
.map-label {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.map-marker:hover .map-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.show-labels .map-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Bring hovered marker to front */
|
||||
.leaflet-marker-icon:hover {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Node Header Hero Map Background
|
||||
========================================================================== */
|
||||
|
||||
#header-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure Leaflet elements stay within the map layer */
|
||||
#header-map .leaflet-pane,
|
||||
#header-map .leaflet-control {
|
||||
z-index: auto !important;
|
||||
}
|
||||
45
src/meshcore_hub/web/static/img/logo.svg
Normal file
45
src/meshcore_hub/web/static/img/logo.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 53 53"
|
||||
width="53"
|
||||
height="53"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="logo_bak.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<!-- WiFi arcs radiating from bottom-left corner -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
id="g3"
|
||||
transform="translate(-1,-16)">
|
||||
<!-- Inner arc: from right to top -->
|
||||
<path
|
||||
d="M 20,65 A 15,15 0 0 0 5,50"
|
||||
id="path1" />
|
||||
<!-- Middle arc -->
|
||||
<path
|
||||
d="M 35,65 A 30,30 0 0 0 5,35"
|
||||
id="path2" />
|
||||
<!-- Outer arc -->
|
||||
<path
|
||||
d="M 50,65 A 45,45 0 0 0 5,20"
|
||||
id="path3" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
12
src/meshcore_hub/web/static/img/meshcore.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 134 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M3.277,0.053C2.829,0.053 2.401,0.41 2.321,0.851L0.013,13.623C-0.067,14.064 0.232,14.421 0.681,14.421L3.13,14.421C3.578,14.421 4.006,14.064 4.086,13.623L5.004,8.54L6.684,13.957C6.766,14.239 7.02,14.421 7.337,14.421L10.58,14.421C10.897,14.421 11.217,14.239 11.401,13.957L15.043,8.513L14.119,13.623C14.038,14.064 14.338,14.421 14.787,14.421L17.236,14.421C17.684,14.421 18.112,14.064 18.192,13.623L20.5,0.851C20.582,0.41 20.283,0.053 19.834,0.053L16.69,0.053C16.373,0.053 16.053,0.235 15.87,0.517L9.897,9.473C9.803,9.616 9.578,9.578 9.528,9.41L7.074,0.517C6.992,0.235 6.738,0.053 6.421,0.053L3.277,0.053Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M21.146,14.421C21.146,14.421 33.257,14.421 33.257,14.421C33.526,14.421 33.784,14.205 33.831,13.942L34.337,11.128C34.385,10.863 34.206,10.649 33.936,10.649L25.519,10.649C25.429,10.649 25.37,10.576 25.385,10.488L25.635,9.105C25.65,9.017 25.736,8.944 25.826,8.944L32.596,8.944C32.865,8.944 33.123,8.728 33.171,8.465L33.621,5.974C33.669,5.709 33.49,5.495 33.221,5.495L26.45,5.495C26.361,5.495 26.301,5.423 26.317,5.335L26.584,3.852C26.599,3.764 26.685,3.691 26.775,3.691L35.192,3.691C35.462,3.691 35.719,3.476 35.767,3.21L36.258,0.498C36.306,0.235 36.126,0.019 35.857,0.019L23.746,0.019C23.297,0.019 22.867,0.378 22.788,0.819L20.474,13.621C20.396,14.062 20.695,14.421 21.146,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M45.926,14.419L45.926,14.421L46.346,14.421C48.453,14.421 50.465,12.742 50.839,10.67L51.081,9.327C51.456,7.256 50.05,5.576 47.943,5.576L41.455,5.576C41.186,5.576 41.007,5.363 41.054,5.097L41.218,4.192C41.266,3.927 41.524,3.713 41.793,3.713L50.569,3.713C51.018,3.713 51.446,3.356 51.526,2.915L51.9,0.85C51.98,0.407 51.68,0.05 51.232,0.05L41.638,0.05C39.531,0.05 37.519,1.73 37.145,3.801L36.88,5.267C36.505,7.339 37.91,9.018 40.018,9.018L46.506,9.018C46.775,9.018 46.954,9.231 46.907,9.497L46.785,10.176C46.737,10.441 46.479,10.655 46.21,10.655L37.189,10.655C36.741,10.655 36.313,11.012 36.233,11.453L35.841,13.621C35.761,14.062 36.061,14.419 36.51,14.419L45.926,14.419Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M68.008,0.046C68.008,0.046 65.296,0.046 65.296,0.046C64.847,0.046 64.42,0.403 64.34,0.844L63.532,5.31C63.517,5.398 63.431,5.469 63.341,5.469L58.085,5.469C57.995,5.469 57.936,5.398 57.951,5.31L58.758,0.844C58.837,0.403 58.539,0.046 58.09,0.046L55.378,0.046C54.93,0.046 54.502,0.403 54.422,0.844L52.112,13.623C52.032,14.064 52.331,14.421 52.78,14.421L55.492,14.421C55.941,14.421 56.369,14.064 56.449,13.623L57.272,9.074C57.287,8.986 57.373,8.914 57.462,8.914L62.719,8.914C62.809,8.914 62.868,8.985 62.853,9.074L62.032,13.623C61.952,14.064 62.252,14.421 62.7,14.421L65.413,14.421C65.861,14.421 66.289,14.064 66.369,13.623L68.678,0.844C68.755,0.403 68.457,0.046 68.008,0.046Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M72.099,14.421C72.099,14.421 80.066,14.421 80.066,14.421C80.515,14.421 80.943,14.064 81.022,13.623L81.414,11.453C81.494,11.012 81.194,10.655 80.746,10.655L73.828,10.655C73.559,10.655 73.38,10.441 73.427,10.176L74.51,4.215C74.558,3.951 74.815,3.736 75.082,3.736L82,3.736C82.448,3.736 82.876,3.379 82.956,2.938L83.34,0.817C83.42,0.376 83.12,0.019 82.672,0.019L74.724,0.019C72.622,0.019 70.614,1.691 70.236,3.757L68.965,10.665C68.587,12.738 69.99,14.421 72.099,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M97.176,-0C97.176,0 88.882,0 88.882,0C86.775,0 84.763,1.68 84.389,3.751L83.139,10.67C82.765,12.741 84.169,14.421 86.277,14.421L94.571,14.421C96.678,14.421 98.69,12.741 99.064,10.67L100.314,3.751C100.689,1.68 99.284,-0 97.176,-0ZM94.798,10.178C94.75,10.443 94.492,10.657 94.223,10.657L87.978,10.657C87.709,10.657 87.529,10.443 87.577,10.178L88.659,4.192C88.707,3.927 88.964,3.713 89.234,3.713L95.477,3.713C95.747,3.713 95.926,3.927 95.878,4.192L94.798,10.178Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M101.284,14.421L103.995,14.421C104.443,14.421 104.871,14.065 104.951,13.624L105.43,10.97C105.446,10.882 105.531,10.81 105.621,10.81L108.902,10.806C109.064,10.806 109.2,10.886 109.267,11.018L110.813,14.035C110.992,14.392 111.319,14.434 112.303,14.419C112.88,14.426 113.756,14.382 115.169,14.382C115.623,14.382 115.902,13.907 115.678,13.51L113.989,10.569C113.945,10.491 113.993,10.386 114.086,10.34C115.39,9.707 116.423,8.477 116.681,7.055L117.27,3.785C117.646,1.713 116.242,0.033 114.134,0.033L103.884,0.033C103.436,0.033 103.008,0.39 102.928,0.831L100.616,13.623C100.536,14.064 100.836,14.421 101.284,14.421L101.284,14.421ZM106.73,3.791C106.745,3.703 106.831,3.631 106.921,3.631L112.225,3.631C112.626,3.631 112.891,3.949 112.821,4.343L112.431,6.494C112.359,6.885 111.979,7.204 111.58,7.204L106.276,7.204C106.186,7.204 106.127,7.133 106.142,7.043L106.73,3.791Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
<path d="M118.277,14.421C118.277,14.421 130.388,14.421 130.388,14.421C130.657,14.421 130.915,14.205 130.963,13.942L131.468,11.128C131.516,10.863 131.337,10.649 131.068,10.649L122.65,10.649C122.56,10.649 122.501,10.576 122.516,10.488L122.766,9.105C122.781,9.017 122.867,8.944 122.957,8.944L129.728,8.944C129.997,8.944 130.254,8.728 130.302,8.465L130.753,5.974C130.801,5.709 130.621,5.495 130.352,5.495L123.581,5.495C123.492,5.495 123.432,5.423 123.448,5.335L123.715,3.852C123.73,3.764 123.816,3.691 123.906,3.691L132.324,3.691C132.593,3.691 132.851,3.476 132.898,3.21L133.389,0.498C133.437,0.235 133.257,0.019 132.988,0.019L120.877,0.019C120.428,0.019 119.999,0.378 119.919,0.819L117.605,13.621C117.527,14.062 117.827,14.421 118.277,14.421Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
216
src/meshcore_hub/web/static/js/charts.js
Normal file
216
src/meshcore_hub/web/static/js/charts.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* MeshCore Hub - Chart.js Helpers
|
||||
*
|
||||
* Provides common chart configuration and initialization helpers
|
||||
* for activity charts used on home and dashboard pages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OKLCH color values for consistent theming
|
||||
*/
|
||||
const ChartColors = {
|
||||
// Primary (purple/blue)
|
||||
primary: 'oklch(0.65 0.24 265)',
|
||||
primaryFill: 'oklch(0.65 0.24 265 / 0.1)',
|
||||
|
||||
// Secondary (pink/magenta)
|
||||
secondary: 'oklch(0.7 0.17 330)',
|
||||
secondaryFill: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
|
||||
// Accent (teal/cyan)
|
||||
accent: 'oklch(0.75 0.18 180)',
|
||||
accentFill: 'oklch(0.75 0.18 180 / 0.1)',
|
||||
|
||||
// Neutral grays
|
||||
grid: 'oklch(0.4 0 0 / 0.2)',
|
||||
text: 'oklch(0.7 0 0)',
|
||||
tooltipBg: 'oklch(0.25 0 0)',
|
||||
tooltipText: 'oklch(0.9 0 0)',
|
||||
tooltipBorder: 'oklch(0.4 0 0)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create common chart options with optional legend
|
||||
* @param {boolean} showLegend - Whether to show the legend
|
||||
* @returns {Object} Chart.js options object
|
||||
*/
|
||||
function createChartOptions(showLegend) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: ChartColors.text,
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date labels for chart display (e.g., "8 Feb")
|
||||
* @param {Array} data - Array of objects with 'date' property
|
||||
* @returns {Array} Formatted date strings
|
||||
*/
|
||||
function formatDateLabels(data) {
|
||||
return data.map(function(d) {
|
||||
var date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single-dataset line chart
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} data - Data object with 'data' array containing {date, count} objects
|
||||
* @param {string} label - Dataset label
|
||||
* @param {string} borderColor - Line color
|
||||
* @param {string} backgroundColor - Fill color
|
||||
* @param {boolean} fill - Whether to fill under the line
|
||||
*/
|
||||
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data || !data.data || data.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatDateLabels(data.data),
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: data.data.map(function(d) { return d.count; }),
|
||||
borderColor: borderColor,
|
||||
backgroundColor: backgroundColor,
|
||||
fill: fill,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: createChartOptions(false)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-dataset activity chart (for home page)
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} advertData - Advertisement data with 'data' array
|
||||
* @param {Object} messageData - Message data with 'data' array
|
||||
*/
|
||||
function createActivityChart(canvasId, advertData, messageData) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !advertData || !advertData.data || advertData.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var labels = formatDateLabels(advertData.data);
|
||||
var advertCounts = advertData.data.map(function(d) { return d.count; });
|
||||
var messageCounts = messageData && messageData.data
|
||||
? messageData.data.map(function(d) { return d.count; })
|
||||
: [];
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertCounts,
|
||||
borderColor: ChartColors.secondary,
|
||||
backgroundColor: ChartColors.secondaryFill,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}, {
|
||||
label: 'Messages',
|
||||
data: messageCounts,
|
||||
borderColor: ChartColors.accent,
|
||||
backgroundColor: ChartColors.accentFill,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: createChartOptions(true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dashboard charts (nodes, advertisements, messages)
|
||||
* @param {Object} nodeData - Node count data
|
||||
* @param {Object} advertData - Advertisement data
|
||||
* @param {Object} messageData - Message data
|
||||
*/
|
||||
function initDashboardCharts(nodeData, advertData, messageData) {
|
||||
// Node count chart (primary color)
|
||||
createLineChart(
|
||||
'nodeChart',
|
||||
nodeData,
|
||||
'Total Nodes',
|
||||
ChartColors.primary,
|
||||
ChartColors.primaryFill,
|
||||
true
|
||||
);
|
||||
|
||||
// Advertisements chart (secondary color)
|
||||
createLineChart(
|
||||
'advertChart',
|
||||
advertData,
|
||||
'Advertisements',
|
||||
ChartColors.secondary,
|
||||
ChartColors.secondaryFill,
|
||||
true
|
||||
);
|
||||
|
||||
// Messages chart (accent color)
|
||||
createLineChart(
|
||||
'messageChart',
|
||||
messageData,
|
||||
'Messages',
|
||||
ChartColors.accent,
|
||||
ChartColors.accentFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
388
src/meshcore_hub/web/static/js/map-main.js
Normal file
388
src/meshcore_hub/web/static/js/map-main.js
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* MeshCore Hub - Main Map Page
|
||||
*
|
||||
* Full map functionality with filters, markers, and clustering.
|
||||
* Requires Leaflet.js to be loaded before this script.
|
||||
*
|
||||
* Configuration:
|
||||
* - Set window.mapConfig.logoUrl before loading this script
|
||||
* - Set window.mapConfig.dataUrl for the data endpoint (default: '/map/data')
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration (can be set before script loads)
|
||||
var config = window.mapConfig || {};
|
||||
var logoUrl = config.logoUrl || '/static/img/logo.svg';
|
||||
var dataUrl = config.dataUrl || '/map/data';
|
||||
|
||||
// Initialize map with world view (will be centered on nodes once loaded)
|
||||
var map = L.map('map').setView([0, 0], 2);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Store all nodes and markers
|
||||
var allNodes = [];
|
||||
var allMembers = [];
|
||||
var markers = [];
|
||||
var mapCenter = { lat: 0, lon: 0 };
|
||||
var infraCenter = null;
|
||||
|
||||
// Maximum radius (km) from anchor point for bounds calculation
|
||||
var MAX_BOUNDS_RADIUS_KM = 20;
|
||||
|
||||
// Padding for fitBounds - more padding on mobile for tighter zoom
|
||||
var isMobilePortrait = window.innerWidth < 480;
|
||||
var isMobile = window.innerWidth < 768;
|
||||
var BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in km (Haversine formula)
|
||||
*/
|
||||
function getDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
var R = 6371; // Earth's radius in km
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes within radius of anchor point for bounds calculation
|
||||
*/
|
||||
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
|
||||
return nodes.filter(function(n) {
|
||||
return getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anchor point for bounds calculation (infra center or nodes center)
|
||||
*/
|
||||
function getAnchorPoint(nodes) {
|
||||
if (infraCenter) {
|
||||
return infraCenter;
|
||||
}
|
||||
// Fall back to center of provided nodes
|
||||
if (nodes.length === 0) return { lat: 0, lon: 0 };
|
||||
return {
|
||||
lat: nodes.reduce(function(sum, n) { return sum + n.lat; }, 0) / nodes.length,
|
||||
lon: nodes.reduce(function(sum, n) { return sum + n.lon; }, 0) / nodes.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize adv_type to lowercase for consistent comparison
|
||||
*/
|
||||
function normalizeType(type) {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for node type
|
||||
*/
|
||||
function getTypeDisplay(node) {
|
||||
var type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return 'Chat';
|
||||
if (type === 'repeater') return 'Repeater';
|
||||
if (type === 'room') return 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create marker icon for a node
|
||||
*/
|
||||
function createNodeIcon(node) {
|
||||
var displayName = node.name || '';
|
||||
var relativeTime = typeof formatRelativeTime === 'function' ? formatRelativeTime(node.last_seen) : '';
|
||||
var timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
|
||||
|
||||
// Use red circle for infrastructure nodes, blue circle for others
|
||||
var iconHtml;
|
||||
if (node.is_infra) {
|
||||
iconHtml = '<div style="width: 12px; height: 12px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%; box-shadow: 0 0 4px rgba(239,68,68,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
} else {
|
||||
iconHtml = '<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
|
||||
iconHtml +
|
||||
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' + displayName + timeDisplay + '</span>' +
|
||||
'</div>',
|
||||
iconSize: [120, 50],
|
||||
iconAnchor: [60, 12]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type emoji for node
|
||||
*/
|
||||
function getTypeEmoji(node) {
|
||||
var type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return '💬';
|
||||
if (type === 'repeater') return '📡';
|
||||
if (type === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup content for a node
|
||||
*/
|
||||
function createPopupContent(node) {
|
||||
var ownerHtml = '';
|
||||
if (node.owner) {
|
||||
var ownerDisplay = node.owner.callsign
|
||||
? node.owner.name + ' (' + node.owner.callsign + ')'
|
||||
: node.owner.name;
|
||||
ownerHtml = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
|
||||
}
|
||||
|
||||
var roleHtml = '';
|
||||
if (node.role) {
|
||||
roleHtml = '<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">' + node.role + '</span></p>';
|
||||
}
|
||||
|
||||
var typeDisplay = getTypeDisplay(node);
|
||||
var typeEmoji = getTypeEmoji(node);
|
||||
|
||||
// Infra indicator (red/blue dot) shown to the right of the title if is_infra is defined
|
||||
var infraIndicatorHtml = '';
|
||||
if (typeof node.is_infra !== 'undefined') {
|
||||
var dotColor = node.is_infra ? '#ef4444' : '#3b82f6';
|
||||
var borderColor = node.is_infra ? '#b91c1c' : '#1e40af';
|
||||
var title = node.is_infra ? 'Infrastructure' : 'Public';
|
||||
infraIndicatorHtml = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
|
||||
}
|
||||
|
||||
var lastSeenHtml = node.last_seen
|
||||
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
: '';
|
||||
|
||||
return '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + typeEmoji + ' ' + node.name + infraIndicatorHtml + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
'<p><span class="opacity-70">Type:</span> ' + typeDisplay + '</p>' +
|
||||
roleHtml +
|
||||
ownerHtml +
|
||||
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + node.public_key.substring(0, 16) + '...</code></p>' +
|
||||
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
|
||||
lastSeenHtml +
|
||||
'</div>' +
|
||||
'<a href="/nodes/' + node.public_key + '" class="btn btn-outline btn-xs mt-3">View Details</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all markers from map
|
||||
*/
|
||||
function clearMarkers() {
|
||||
markers.forEach(function(marker) {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
markers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Core filter logic - returns filtered nodes and updates markers
|
||||
*/
|
||||
function applyFiltersCore() {
|
||||
var categoryFilter = document.getElementById('filter-category').value;
|
||||
var typeFilter = document.getElementById('filter-type').value;
|
||||
var memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
var filteredNodes = allNodes.filter(function(node) {
|
||||
// Category filter (infrastructure only)
|
||||
if (categoryFilter === 'infra' && !node.is_infra) return false;
|
||||
|
||||
// Type filter (case-insensitive)
|
||||
var nodeType = normalizeType(node.adv_type);
|
||||
if (typeFilter && nodeType !== typeFilter) return false;
|
||||
|
||||
// Member filter - match node's member_id tag to selected member_id
|
||||
if (memberFilter) {
|
||||
if (node.member_id !== memberFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Clear existing markers
|
||||
clearMarkers();
|
||||
|
||||
// Add filtered markers
|
||||
filteredNodes.forEach(function(node) {
|
||||
var marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
|
||||
marker.bindPopup(createPopupContent(node));
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
// Update counts
|
||||
var countEl = document.getElementById('node-count');
|
||||
var filteredEl = document.getElementById('filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = allNodes.length + ' nodes on map';
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = allNodes.length + ' total';
|
||||
filteredEl.textContent = filteredNodes.length + ' shown';
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters and recenter map on filtered nodes
|
||||
*/
|
||||
function applyFilters() {
|
||||
var filteredNodes = applyFiltersCore();
|
||||
var categoryFilter = document.getElementById('filter-category').value;
|
||||
|
||||
// Fit bounds if we have filtered nodes
|
||||
if (filteredNodes.length > 0) {
|
||||
var nodesToFit = filteredNodes;
|
||||
|
||||
// Apply radius filter when showing all nodes (not infra-only)
|
||||
if (categoryFilter !== 'infra') {
|
||||
var anchor = getAnchorPoint(filteredNodes);
|
||||
var nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
if (nearbyNodes.length > 0) {
|
||||
nodesToFit = nearbyNodes;
|
||||
}
|
||||
}
|
||||
|
||||
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters without recentering (for initial load after manual center)
|
||||
*/
|
||||
function applyFiltersNoRecenter() {
|
||||
applyFiltersCore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate member filter dropdown
|
||||
*/
|
||||
function populateMemberFilter() {
|
||||
var select = document.getElementById('filter-member');
|
||||
|
||||
// Sort members by name
|
||||
var sortedMembers = allMembers.slice().sort(function(a, b) {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Add options for all members
|
||||
sortedMembers.forEach(function(member) {
|
||||
if (member.member_id) {
|
||||
var option = document.createElement('option');
|
||||
option.value = member.member_id;
|
||||
option.textContent = member.callsign
|
||||
? member.name + ' (' + member.callsign + ')'
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-category').value = '';
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-member').value = '';
|
||||
document.getElementById('show-labels').checked = false;
|
||||
updateLabelVisibility();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle label visibility
|
||||
*/
|
||||
function updateLabelVisibility() {
|
||||
var showLabels = document.getElementById('show-labels').checked;
|
||||
var mapEl = document.getElementById('map');
|
||||
if (showLabels) {
|
||||
mapEl.classList.add('show-labels');
|
||||
} else {
|
||||
mapEl.classList.remove('show-labels');
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-category').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').addEventListener('change', applyFilters);
|
||||
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
fetch(dataUrl)
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
allNodes = data.nodes;
|
||||
allMembers = data.members || [];
|
||||
mapCenter = data.center;
|
||||
infraCenter = data.infra_center;
|
||||
|
||||
// Log debug info
|
||||
var debug = data.debug || {};
|
||||
console.log('Map data loaded:', debug);
|
||||
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
|
||||
|
||||
if (debug.error) {
|
||||
document.getElementById('node-count').textContent = 'Error: ' + debug.error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
document.getElementById('node-count').textContent = 'No nodes in database';
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.nodes_with_coords === 0) {
|
||||
document.getElementById('node-count').textContent = debug.total_nodes + ' nodes (none have coordinates)';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate member filter
|
||||
populateMemberFilter();
|
||||
|
||||
// Initial display - center map on infrastructure nodes if available, else nodes within radius
|
||||
var infraNodes = allNodes.filter(function(n) { return n.is_infra; });
|
||||
if (infraNodes.length > 0) {
|
||||
var bounds = L.latLngBounds(infraNodes.map(function(n) { return [n.lat, n.lon]; }));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (allNodes.length > 0) {
|
||||
// Use radius filter to exclude outliers
|
||||
var anchor = getAnchorPoint(allNodes);
|
||||
var nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
var nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
|
||||
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
}
|
||||
|
||||
// Apply filters (won't re-center since we just did above)
|
||||
applyFiltersNoRecenter();
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error loading map data:', error);
|
||||
document.getElementById('node-count').textContent = 'Error loading data';
|
||||
});
|
||||
})();
|
||||
121
src/meshcore_hub/web/static/js/map-node.js
Normal file
121
src/meshcore_hub/web/static/js/map-node.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* MeshCore Hub - Node Detail Map
|
||||
*
|
||||
* Simple map for displaying a single node's location.
|
||||
* Requires Leaflet.js to be loaded before this script.
|
||||
*
|
||||
* Configuration via window.nodeMapConfig:
|
||||
* - lat: Node latitude (required)
|
||||
* - lon: Node longitude (required)
|
||||
* - name: Node display name (required)
|
||||
* - type: Node adv_type (optional)
|
||||
* - publicKey: Node public key (optional, for linking)
|
||||
* - elementId: Map container element ID (default: 'node-map')
|
||||
* - interactive: Enable map interactions (default: true)
|
||||
* - zoom: Initial zoom level (default: 15)
|
||||
* - showMarker: Show node marker (default: true)
|
||||
* - offsetX: Horizontal position of node (0-1, default: 0.5 = center)
|
||||
* - offsetY: Vertical position of node (0-1, default: 0.5 = center)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Get configuration
|
||||
var config = window.nodeMapConfig;
|
||||
if (!config || typeof config.lat === 'undefined' || typeof config.lon === 'undefined') {
|
||||
console.warn('Node map config missing or invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeLat = config.lat;
|
||||
var nodeLon = config.lon;
|
||||
var nodeName = config.name || 'Unnamed Node';
|
||||
var nodeType = config.type || '';
|
||||
var elementId = config.elementId || 'node-map';
|
||||
var interactive = config.interactive !== false; // Default true
|
||||
var zoomLevel = config.zoom || 15;
|
||||
var showMarker = config.showMarker !== false; // Default true
|
||||
var offsetX = typeof config.offsetX === 'number' ? config.offsetX : 0.5; // 0-1, default center
|
||||
var offsetY = typeof config.offsetY === 'number' ? config.offsetY : 0.5; // 0-1, default center
|
||||
|
||||
// Check if map container exists
|
||||
var mapContainer = document.getElementById(elementId);
|
||||
if (!mapContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build map options
|
||||
var mapOptions = {};
|
||||
|
||||
// Disable interactions if non-interactive
|
||||
if (!interactive) {
|
||||
mapOptions.dragging = false;
|
||||
mapOptions.touchZoom = false;
|
||||
mapOptions.scrollWheelZoom = false;
|
||||
mapOptions.doubleClickZoom = false;
|
||||
mapOptions.boxZoom = false;
|
||||
mapOptions.keyboard = false;
|
||||
mapOptions.zoomControl = false;
|
||||
mapOptions.attributionControl = false;
|
||||
}
|
||||
|
||||
// Initialize map centered on the node's location
|
||||
var map = L.map(elementId, mapOptions).setView([nodeLat, nodeLon], zoomLevel);
|
||||
|
||||
// Apply offset to position node at specified location instead of center
|
||||
// offsetX/Y of 0.5 = center (no pan), 0.33 = 1/3 from left/top
|
||||
if (offsetX !== 0.5 || offsetY !== 0.5) {
|
||||
var containerWidth = mapContainer.offsetWidth;
|
||||
var containerHeight = mapContainer.offsetHeight;
|
||||
// Pan amount: how far to move the map so node appears at offset position
|
||||
// Positive X = pan right (node moves left), Positive Y = pan down (node moves up)
|
||||
var panX = (0.5 - offsetX) * containerWidth;
|
||||
var panY = (0.5 - offsetY) * containerHeight;
|
||||
map.panBy([panX, panY], { animate: false });
|
||||
}
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Only add marker if showMarker is true
|
||||
if (showMarker) {
|
||||
/**
|
||||
* Get emoji marker based on node type
|
||||
*/
|
||||
function getNodeEmoji(type) {
|
||||
var normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
var emoji = getNodeEmoji(nodeType);
|
||||
var icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Only add popup if map is interactive
|
||||
if (interactive) {
|
||||
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
|
||||
var popupContent = '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
typeHtml +
|
||||
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
marker.bindPopup(popupContent);
|
||||
}
|
||||
}
|
||||
})();
|
||||
62
src/meshcore_hub/web/static/js/qrcode-init.js
Normal file
62
src/meshcore_hub/web/static/js/qrcode-init.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* MeshCore Hub - QR Code Generation
|
||||
*
|
||||
* Generates QR codes for adding MeshCore contacts.
|
||||
* Requires qrcodejs library to be loaded before this script.
|
||||
*
|
||||
* Configuration via window.qrCodeConfig:
|
||||
* - name: Contact name (required)
|
||||
* - publicKey: 64-char hex public key (required)
|
||||
* - advType: Node advertisement type (optional)
|
||||
* - containerId: ID of container element (default: 'qr-code')
|
||||
* - size: QR code size in pixels (default: 128)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Get configuration
|
||||
var config = window.qrCodeConfig;
|
||||
if (!config || !config.publicKey) {
|
||||
console.warn('QR code config missing or invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeName = config.name || 'Node';
|
||||
var publicKey = config.publicKey;
|
||||
var advType = config.advType || '';
|
||||
var containerId = config.containerId || 'qr-code';
|
||||
var size = config.size || 128;
|
||||
|
||||
// Map adv_type to numeric type for meshcore:// protocol
|
||||
var typeMap = {
|
||||
'chat': 1,
|
||||
'repeater': 2,
|
||||
'room': 3,
|
||||
'sensor': 4
|
||||
};
|
||||
var typeNum = typeMap[advType.toLowerCase()] || 1;
|
||||
|
||||
// Build meshcore:// URL
|
||||
var meshcoreUrl = 'meshcore://contact/add?name=' + encodeURIComponent(nodeName) +
|
||||
'&public_key=' + publicKey +
|
||||
'&type=' + typeNum;
|
||||
|
||||
// Generate QR code
|
||||
var qrContainer = document.getElementById(containerId);
|
||||
if (qrContainer && typeof QRCode !== 'undefined') {
|
||||
try {
|
||||
new QRCode(qrContainer, {
|
||||
text: meshcoreUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('QR code generation failed:', error);
|
||||
qrContainer.innerHTML = '<p class="text-sm opacity-50">QR code unavailable</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
104
src/meshcore_hub/web/static/js/utils.js
Normal file
104
src/meshcore_hub/web/static/js/utils.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* MeshCore Hub - Common JavaScript Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object
|
||||
* @returns {string} Relative time string, or empty string if invalid
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate all elements with data-timestamp attribute with relative time
|
||||
*/
|
||||
function populateRelativeTimestamps() {
|
||||
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
|
||||
const timestamp = el.dataset.timestamp;
|
||||
if (timestamp) {
|
||||
el.textContent = formatRelativeTime(timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate receiver tooltip elements with name and relative time
|
||||
*/
|
||||
function populateReceiverTooltips() {
|
||||
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
const timestamp = el.dataset.timestamp;
|
||||
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
|
||||
|
||||
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
|
||||
let tooltip = name;
|
||||
if (relTime) {
|
||||
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
|
||||
}
|
||||
el.title = tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate <time> elements with data-relative-time attribute
|
||||
* Uses the datetime attribute as the timestamp source
|
||||
*/
|
||||
function populateRelativeTimeElements() {
|
||||
document.querySelectorAll('time[data-relative-time]').forEach(el => {
|
||||
const timestamp = el.getAttribute('datetime');
|
||||
if (timestamp) {
|
||||
const relTime = formatRelativeTime(timestamp);
|
||||
el.textContent = relTime ? `${relTime} ago` : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-submit behavior for filter forms
|
||||
* Forms with data-auto-submit attribute will auto-submit on:
|
||||
* - Change events on select and checkbox inputs
|
||||
* - Enter key on text inputs
|
||||
*/
|
||||
function initAutoSubmitForms() {
|
||||
document.querySelectorAll('form[data-auto-submit]').forEach(form => {
|
||||
// Auto-submit on select/checkbox change
|
||||
form.querySelectorAll('select, input[type="checkbox"]').forEach(el => {
|
||||
el.addEventListener('change', () => form.submit());
|
||||
});
|
||||
|
||||
// Submit on Enter key for text inputs
|
||||
form.querySelectorAll('input[type="text"]').forEach(el => {
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRelativeTimestamps();
|
||||
populateReceiverTooltips();
|
||||
populateRelativeTimeElements();
|
||||
initAutoSubmitForms();
|
||||
});
|
||||
47
src/meshcore_hub/web/templates/_macros.html
Normal file
47
src/meshcore_hub/web/templates/_macros.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{# Reusable macros for templates #}
|
||||
|
||||
{#
|
||||
Pagination macro
|
||||
|
||||
Parameters:
|
||||
- page: Current page number
|
||||
- total_pages: Total number of pages
|
||||
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
|
||||
#}
|
||||
{% macro pagination(page, total_pages, params={}) %}
|
||||
{% if total_pages > 1 %}
|
||||
{% set query_parts = [] %}
|
||||
{% for key, value in params.items() %}
|
||||
{% if value is not none and value != '' %}
|
||||
{% set _ = query_parts.append(key ~ '=' ~ value) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set base_query = query_parts|join('&') %}
|
||||
{% set query_prefix = '&' if base_query else '' %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
19
src/meshcore_hub/web/templates/admin/access_denied.html
Normal file
19
src/meshcore_hub/web/templates/admin/access_denied.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_lock %}
|
||||
|
||||
{% block title %}Access Denied - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<div class="text-center">
|
||||
{{ icon_lock("h-24 w-24 mx-auto text-error opacity-50 mb-6") }}
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
|
||||
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">Return Home</a>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
57
src/meshcore_hub/web/templates/admin/index.html
Normal file
57
src/meshcore_hub/web/templates/admin/index.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_user, icon_email, icon_users, icon_tag %}
|
||||
|
||||
{% block title %}Admin - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li>Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated User Info -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
|
||||
{% if auth_username or auth_user %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
{{ icon_user("h-4 w-4") }}
|
||||
{{ auth_username or auth_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if auth_email %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
{{ icon_email("h-4 w-4") }}
|
||||
{{ auth_email }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Navigation Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ icon_users("h-6 w-6") }}
|
||||
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">
|
||||
{{ icon_tag("h-6 w-6") }}
|
||||
Node Tags
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
277
src/meshcore_hub/web/templates/admin/members.html
Normal file
277
src/meshcore_hub/web/templates/admin/members.html
Normal file
@@ -0,0 +1,277 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_success, icon_error, icon_alert %}
|
||||
|
||||
{% block title %}Admin: Members - {{ network_name }}{% 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">
|
||||
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
|
||||
<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">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<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 %}
|
||||
419
src/meshcore_hub/web/templates/admin/node_tags.html
Normal file
419
src/meshcore_hub/web/templates/admin/node_tags.html
Normal file
@@ -0,0 +1,419 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_success, icon_error, icon_alert, icon_info, icon_tag %}
|
||||
|
||||
{% block title %}Admin: Node Tags - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Node Tags</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Node Tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Selector -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Select Node</h2>
|
||||
<form method="get" action="/a/node-tags" class="flex gap-4 items-end">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<option value="">-- Select a node --</option>
|
||||
{% for node in nodes %}
|
||||
<option value="{{ node.public_key }}" {% if node.public_key == selected_public_key %}selected{% endif %}>
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Load Tags</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_public_key and selected_node %}
|
||||
<!-- Selected Node Info -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl" title="{{ selected_node.adv_type or 'Unknown' }}">{% if selected_node.adv_type and selected_node.adv_type|lower == 'chat' %}💬{% elif selected_node.adv_type and selected_node.adv_type|lower == 'repeater' %}📡{% elif selected_node.adv_type and selected_node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
<h2 class="card-title">{{ selected_node.name or 'Unnamed Node' }}</h2>
|
||||
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if tags %}
|
||||
<button class="btn btn-outline btn-sm" onclick="copyAllModal.showModal()">Copy All</button>
|
||||
<button class="btn btn-outline btn-error btn-sm" onclick="deleteAllModal.showModal()">Delete All</button>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Table -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Tags ({{ tags|length }})</h2>
|
||||
|
||||
{% if tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th class="w-48">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in tags %}
|
||||
<tr data-tag-key="{{ tag.key }}" data-tag-value="{{ tag.value or '' }}" data-tag-type="{{ tag.value_type }}">
|
||||
<td class="font-mono font-semibold">{{ tag.key }}</td>
|
||||
<td class="max-w-xs truncate" title="{{ tag.value or '' }}">{{ tag.value or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at|localdate }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-move">
|
||||
Move
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No tags found for this node.</p>
|
||||
<p class="text-sm mt-2">Add a new tag below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Tag Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Add New Tag</h2>
|
||||
<form method="post" action="/a/node-tags" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" class="select select-bordered">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text"> </span>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Add Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<form method="post" action="/a/node-tags/update" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="editKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Key</span>
|
||||
</label>
|
||||
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Value</span>
|
||||
</label>
|
||||
<input type="text" name="value" id="editValue" class="input input-bordered">
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="value_type" id="editValueType" class="select select-bordered w-full">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Move Modal -->
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/move" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="moveKey">
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Tag Key</span>
|
||||
</label>
|
||||
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="new_public_key" id="moveDestination" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This will move the tag from the current node to the destination node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="moveModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Move Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Tag</h3>
|
||||
<form method="post" action="/a/node-tags/delete" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
<input type="hidden" name="key" id="deleteKey">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Copy All Tags Modal -->
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
|
||||
<form method="post" action="/a/node-tags/copy-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Copy all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong> to another node.</p>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Destination Node</span>
|
||||
</label>
|
||||
<select name="dest_public_key" class="select select-bordered w-full" required>
|
||||
<option value="">-- Select destination node --</option>
|
||||
{% for node in nodes %}
|
||||
{% if node.public_key != selected_public_key %}
|
||||
<option value="{{ node.public_key }}">
|
||||
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="copyAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Copy Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete All Tags Modal -->
|
||||
<dialog id="deleteAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete All Tags</h3>
|
||||
<form method="post" action="/a/node-tags/delete-all" class="py-4">
|
||||
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
|
||||
|
||||
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteAllModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete All Tags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% elif selected_public_key and not selected_node %}
|
||||
<div class="alert alert-warning">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Node not found: {{ selected_public_key }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
{{ icon_tag("h-16 w-16 mx-auto mb-4 opacity-30") }}
|
||||
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
|
||||
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
var value = row.dataset.tagValue;
|
||||
var valueType = row.dataset.tagType;
|
||||
document.getElementById('editKey').value = key;
|
||||
document.getElementById('editKeyDisplay').value = key;
|
||||
document.getElementById('editValue').value = value;
|
||||
document.getElementById('editValueType').value = valueType;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Move button handler
|
||||
document.querySelectorAll('.btn-move').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('moveKey').value = key;
|
||||
document.getElementById('moveKeyDisplay').value = key;
|
||||
document.getElementById('moveDestination').selectedIndex = 0;
|
||||
moveModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var key = row.dataset.tagKey;
|
||||
document.getElementById('deleteKey').value = key;
|
||||
document.getElementById('deleteKeyDisplay').textContent = key;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,18 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
{% from "macros/icons.html" import icon_alert %}
|
||||
|
||||
{% block title %}{{ network_name }} - Advertisements{% endblock %}
|
||||
{% block title %}Advertisements - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Advertisements</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<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>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -20,133 +23,143 @@
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
|
||||
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" data-auto-submit>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Search</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% if nodes %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm">
|
||||
<option value="">All Nodes</option>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table -->
|
||||
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<!-- Advertisements List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for ad in advertisements %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at|localtime_short }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No advertisements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Received By</th>
|
||||
<th>Time</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in advertisements %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ ad.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in ad.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif ad.receivers and ad.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ ad.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if ad.receivers[0].tag_name or ad.receivers[0].name %}
|
||||
<div class="font-medium">{{ ad.receivers[0].tag_name or ad.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
|
||||
{% if ad.receiver_tag_name or ad.receiver_name %}
|
||||
<div class="font-medium">{{ ad.receiver_tag_name or ad.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
|
||||
{{ ad.received_at|localtime }}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page, icon_menu %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -5,6 +6,30 @@
|
||||
<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 ~ " - " ~ network_welcome_text) if network_welcome_text else (network_name ~ " - MeshCore off-grid LoRa mesh network dashboard.") %}
|
||||
<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="{{ logo_url }}">
|
||||
|
||||
<!-- Enable View Transitions API for smooth page navigation -->
|
||||
<meta name="view-transition" content="same-origin">
|
||||
|
||||
<!-- 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>
|
||||
@@ -12,35 +37,8 @@
|
||||
<!-- Leaflet CSS for maps -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--b2));
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.5);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.table-compact td, .table-compact th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Truncate text in table cells */
|
||||
.truncate-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<!-- Custom application styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
@@ -50,36 +48,38 @@
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
{{ icon_menu("h-5 w-5") }}
|
||||
</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") }} Advertisements</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="{{ logo_url }}" 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") }} Advertisements</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">
|
||||
@@ -113,14 +113,21 @@
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">YouTube</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Common utilities -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
195
src/meshcore_hub/web/templates/dashboard.html
Normal file
195
src/meshcore_hub/web/templates/dashboard.html
Normal file
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_nodes, icon_advertisements, icon_messages, icon_alert, icon_channel %}
|
||||
|
||||
{% block title %}Dashboard - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6 animate-stagger">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-primary">
|
||||
{{ icon_nodes("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-secondary">
|
||||
{{ icon_advertisements("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-accent">
|
||||
{{ icon_messages("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8 animate-stagger">
|
||||
<!-- Node Count Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
{{ icon_nodes("h-5 w-5") }}
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
{{ icon_advertisements("h-5 w-5") }}
|
||||
Advertisements
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="advertChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
{{ icon_messages("h-5 w-5") }}
|
||||
Messages
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="messageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 animate-stagger">
|
||||
<!-- Recent Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ icon_advertisements("h-6 w-6") }}
|
||||
Recent Advertisements
|
||||
</h2>
|
||||
{% if stats.recent_advertisements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in stats.recent_advertisements %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
|
||||
</a>
|
||||
{% if ad.friendly_name or ad.name %}
|
||||
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right text-sm opacity-70">{{ ad.received_at|localtimeonly }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">No advertisements recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Messages -->
|
||||
{% if stats.channel_messages %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ icon_channel("h-6 w-6") }}
|
||||
Recent Channel Messages
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for channel, messages in stats.channel_messages.items() %}
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
|
||||
<span class="badge badge-info badge-sm">CH{{ channel }}</span>
|
||||
Channel {{ channel }}
|
||||
</h3>
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
{% for msg in messages %}
|
||||
<div class="text-sm">
|
||||
<span class="text-xs opacity-50">{{ msg.received_at|localtimeonly_short }}</span>
|
||||
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var nodeData = {{ node_count_json | safe }};
|
||||
var advertData = {{ advert_activity_json | safe }};
|
||||
var messageData = {{ message_activity_json | safe }};
|
||||
initDashboardCharts(nodeData, advertData, messageData);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
32
src/meshcore_hub/web/templates/errors/404.html
Normal file
32
src/meshcore_hub/web/templates/errors/404.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_home, icon_nodes %}
|
||||
|
||||
{% 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">
|
||||
{{ icon_home("h-5 w-5 mr-2") }}
|
||||
Go Home
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline">
|
||||
{{ icon_nodes("h-5 w-5 mr-2") }}
|
||||
Browse Nodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,100 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page, icon_info, icon_chart, icon_globe, icon_github %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
{% block title %}{{ network_name }}{% 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">
|
||||
<!-- Header: Logo and Title side by side -->
|
||||
<div class="flex items-center gap-8 mb-4">
|
||||
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-36 w-36" />
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="text-6xl font-black tracking-tight">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="text-2xl opacity-70 mt-2">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4 max-w-[70%]">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4 max-w-[70%]">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex flex-wrap justify-center gap-3 mt-auto">
|
||||
<a href="/dashboard" class="btn btn-outline btn-info">
|
||||
{{ icon_dashboard("h-5 w-5 mr-2") }}
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-outline btn-primary">
|
||||
{{ icon_nodes("h-5 w-5 mr-2") }}
|
||||
Nodes
|
||||
</a>
|
||||
<a href="/advertisements" class="btn btn-outline btn-secondary">
|
||||
{{ icon_advertisements("h-5 w-5 mr-2") }}
|
||||
Adverts
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-outline btn-accent">
|
||||
{{ icon_messages("h-5 w-5 mr-2") }}
|
||||
Messages
|
||||
</a>
|
||||
<a href="/map" class="btn btn-outline btn-warning">
|
||||
{{ icon_map("h-5 w-5 mr-2") }}
|
||||
Map
|
||||
</a>
|
||||
{% for page in custom_pages[:3] %}
|
||||
<a href="{{ page.url }}" class="btn btn-outline btn-neutral">
|
||||
{{ icon_page("h-5 w-5 mr-2") }}
|
||||
{{ page.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Column (stacked vertically) -->
|
||||
<div class="flex flex-col gap-4 animate-stagger">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-primary">
|
||||
{{ icon_nodes("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-secondary">
|
||||
{{ icon_advertisements("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-figure text-accent">
|
||||
{{ icon_messages("h-8 w-8") }}
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6 animate-stagger">
|
||||
<!-- Network Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_info("h-6 w-6") }}
|
||||
Network Info
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
@@ -111,18 +113,18 @@
|
||||
<span class="font-mono">{{ network_radio_config.frequency }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.spreading_factor %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Spreading Factor:</span>
|
||||
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.bandwidth %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Bandwidth:</span>
|
||||
<span class="font-mono">{{ network_radio_config.bandwidth }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.spreading_factor %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Spreading Factor:</span>
|
||||
<span class="font-mono">{{ network_radio_config.spreading_factor }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_radio_config.coding_rate %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Coding Rate:</span>
|
||||
@@ -146,59 +148,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Activity Chart -->
|
||||
<!-- Powered by MeshCore -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
Network Activity
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Advertisements received per day (last 7 days)</p>
|
||||
<div class="h-48">
|
||||
<canvas id="activityChart"></canvas>
|
||||
<div class="card-body flex flex-col items-center justify-center">
|
||||
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="h-8" />
|
||||
</a>
|
||||
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
{{ icon_globe("h-4 w-4 mr-1") }}
|
||||
Website
|
||||
</a>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
{{ icon_github("h-4 w-4 mr-1") }}
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Card -->
|
||||
<!-- Network Activity Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Contact
|
||||
{{ icon_chart("h-6 w-6") }}
|
||||
Network Activity
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||
</svg>
|
||||
{{ network_contact_email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not network_contact_email and not network_contact_discord and not network_contact_github %}
|
||||
<p class="text-sm opacity-70">No contact information configured.</p>
|
||||
{% endif %}
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
<div class="h-48">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,82 +187,12 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const activityData = {{ activity_json | safe }};
|
||||
const ctx = document.getElementById('activityChart');
|
||||
|
||||
if (ctx && activityData.data && activityData.data.length > 0) {
|
||||
// Format dates for display (show only day/month)
|
||||
const labels = activityData.data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
const counts = activityData.data.map(d => d.count);
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: counts,
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'oklch(0.25 0 0)',
|
||||
titleColor: 'oklch(0.9 0 0)',
|
||||
bodyColor: 'oklch(0.9 0 0)',
|
||||
borderColor: 'oklch(0.4 0 0)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'oklch(0.4 0 0 / 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'oklch(0.4 0 0 / 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
var advertData = {{ advert_activity_json | safe }};
|
||||
var messageData = {{ message_activity_json | safe }};
|
||||
createActivityChart('activityChart', advertData, messageData);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
143
src/meshcore_hub/web/templates/macros/icons.html
Normal file
143
src/meshcore_hub/web/templates/macros/icons.html
Normal file
@@ -0,0 +1,143 @@
|
||||
{% 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 %}
|
||||
|
||||
{% macro icon_info(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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_alert(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 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_chart(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="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_refresh(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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_menu(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="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_github(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_external_link(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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_globe(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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_error(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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_channel(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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_success(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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_lock(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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_user(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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_email(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 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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_tag(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="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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_users(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="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>
|
||||
{% endmacro %}
|
||||
@@ -1,28 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Map{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#map {
|
||||
height: calc(100vh - 350px);
|
||||
min-height: 400px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}Map - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Node Map</h1>
|
||||
<h1 class="text-3xl font-bold">Map</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
|
||||
</div>
|
||||
@@ -32,6 +16,15 @@
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Show</span>
|
||||
</label>
|
||||
<select id="filter-category" class="select select-bordered select-sm">
|
||||
<option value="">All Nodes</option>
|
||||
<option value="infra">Infrastructure Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node Type</span>
|
||||
@@ -45,17 +38,17 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Owner</span>
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select id="filter-owner" class="select select-bordered select-sm">
|
||||
<option value="">All Owners</option>
|
||||
<select id="filter-member" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Infrastructure Only</span>
|
||||
<input type="checkbox" id="filter-infra" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text">Show Labels</span>
|
||||
<input type="checkbox" id="show-labels" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
|
||||
@@ -73,300 +66,26 @@
|
||||
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
|
||||
<span class="opacity-70">Legend:</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">💬</span>
|
||||
<span>Chat</span>
|
||||
<div style="width: 10px; height: 10px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%;"></div>
|
||||
<span>Infrastructure</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📡</span>
|
||||
<span>Repeater</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">🪧</span>
|
||||
<span>Room</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📍</span>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg" style="filter: drop-shadow(0 0 4px gold);">📡</span>
|
||||
<span>Infrastructure (gold glow)</span>
|
||||
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
|
||||
<span>Public</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags. Infrastructure nodes are tagged with <code>role: infra</code>.</p>
|
||||
<p>Nodes are placed on the map based on GPS coordinates from node reports or manual tags.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Initialize map with world view (will be centered on nodes once loaded)
|
||||
const map = L.map('map').setView([0, 0], 2);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Store all nodes and markers
|
||||
let allNodes = [];
|
||||
let allMembers = [];
|
||||
let markers = [];
|
||||
let mapCenter = { lat: 0, lon: 0 };
|
||||
|
||||
// Normalize adv_type to lowercase for consistent comparison
|
||||
function normalizeType(type) {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Format relative time (e.g., "2m", "1h", "2d")
|
||||
function formatRelativeTime(lastSeenStr) {
|
||||
if (!lastSeenStr) return null;
|
||||
|
||||
const lastSeen = new Date(lastSeenStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - lastSeen;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) return `${diffDay}d`;
|
||||
if (diffHour > 0) return `${diffHour}h`;
|
||||
if (diffMin > 0) return `${diffMin}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return '💬';
|
||||
if (type === 'repeater') return '📡';
|
||||
if (type === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Get display name for node type
|
||||
function getTypeDisplay(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return 'Chat';
|
||||
if (type === 'repeater') return 'Repeater';
|
||||
if (type === 'room') return 'Room';
|
||||
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
// Create marker icon for a node
|
||||
function createNodeIcon(node) {
|
||||
const emoji = getNodeEmoji(node);
|
||||
const infraGlow = node.is_infra ? 'filter: drop-shadow(0 0 4px gold);' : '';
|
||||
const displayName = node.name || '';
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="display: flex; align-items: center; gap: 2px;">
|
||||
<span style="font-size: 24px; ${infraGlow} text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
|
||||
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
|
||||
</div>`,
|
||||
iconSize: [82, 28],
|
||||
iconAnchor: [14, 14]
|
||||
});
|
||||
}
|
||||
|
||||
// Create popup content for a node
|
||||
function createPopupContent(node) {
|
||||
let ownerHtml = '';
|
||||
if (node.owner) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? `${node.owner.name} (${node.owner.callsign})`
|
||||
: node.owner.name;
|
||||
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
|
||||
}
|
||||
|
||||
const emoji = getNodeEmoji(node);
|
||||
const typeDisplay = getTypeDisplay(node);
|
||||
|
||||
return `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
|
||||
${roleHtml}
|
||||
${ownerHtml}
|
||||
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
|
||||
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
|
||||
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
|
||||
</div>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Clear all markers from map
|
||||
function clearMarkers() {
|
||||
markers.forEach(marker => map.removeLayer(marker));
|
||||
markers = [];
|
||||
}
|
||||
|
||||
// Core filter logic - returns filtered nodes and updates markers
|
||||
function applyFiltersCore() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const ownerFilter = document.getElementById('filter-owner').value;
|
||||
const infraOnly = document.getElementById('filter-infra').checked;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// Infrastructure filter
|
||||
if (infraOnly && !node.is_infra) return false;
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter) {
|
||||
if (!node.owner || node.owner.public_key !== ownerFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Clear existing markers
|
||||
clearMarkers();
|
||||
|
||||
// Add filtered markers
|
||||
filteredNodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
|
||||
marker.bindPopup(createPopupContent(node));
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
// Update counts
|
||||
const countEl = document.getElementById('node-count');
|
||||
const filteredEl = document.getElementById('filtered-count');
|
||||
|
||||
if (filteredNodes.length === allNodes.length) {
|
||||
countEl.textContent = `${allNodes.length} nodes on map`;
|
||||
filteredEl.classList.add('hidden');
|
||||
} else {
|
||||
countEl.textContent = `${allNodes.length} total`;
|
||||
filteredEl.textContent = `${filteredNodes.length} shown`;
|
||||
filteredEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
// Apply filters and recenter map on filtered nodes
|
||||
function applyFilters() {
|
||||
const filteredNodes = applyFiltersCore();
|
||||
|
||||
// Fit bounds if we have filtered nodes
|
||||
if (filteredNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters without recentering (for initial load after manual center)
|
||||
function applyFiltersNoRecenter() {
|
||||
applyFiltersCore();
|
||||
}
|
||||
|
||||
// Populate owner filter dropdown
|
||||
function populateOwnerFilter() {
|
||||
const select = document.getElementById('filter-owner');
|
||||
|
||||
// Get unique owners from nodes that have locations
|
||||
const ownersWithNodes = new Set();
|
||||
allNodes.forEach(node => {
|
||||
if (node.owner) {
|
||||
ownersWithNodes.add(node.owner.public_key);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter members to only those who own nodes on the map
|
||||
const relevantMembers = allMembers.filter(m => ownersWithNodes.has(m.public_key));
|
||||
|
||||
// Sort by name
|
||||
relevantMembers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options
|
||||
relevantMembers.forEach(member => {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.public_key;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-owner').value = '';
|
||||
document.getElementById('filter-infra').checked = false;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-owner').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-infra').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
fetch('/map/data')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
allNodes = data.nodes;
|
||||
allMembers = data.members || [];
|
||||
mapCenter = data.center;
|
||||
|
||||
// Log debug info
|
||||
const debug = data.debug || {};
|
||||
console.log('Map data loaded:', debug);
|
||||
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
|
||||
|
||||
if (debug.error) {
|
||||
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
document.getElementById('node-count').textContent = 'No nodes in database';
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug.nodes_with_coords === 0) {
|
||||
document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate owner filter
|
||||
populateOwnerFilter();
|
||||
|
||||
// Initial display - center map on nodes if available
|
||||
if (allNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(allNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
|
||||
// Apply filters (won't re-center since we just did above)
|
||||
applyFiltersNoRecenter();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading map data:', error);
|
||||
document.getElementById('node-count').textContent = 'Error loading data';
|
||||
});
|
||||
window.mapConfig = {
|
||||
logoUrl: "{{ logo_url }}",
|
||||
dataUrl: "/map/data"
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/map-main.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_info %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members{% endblock %}
|
||||
{% block title %}Members - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Members</h1>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<span class="badge badge-lg">{{ members|length }} members</span>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
📦
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if display_name %}
|
||||
<div class="font-medium text-sm">{{ display_name }}</div>
|
||||
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
|
||||
@@ -58,6 +59,9 @@
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen|localtime }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -68,9 +72,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
|
||||
<div>
|
||||
<h3 class="font-bold">No members configured</h3>
|
||||
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
{% from "macros/icons.html" import icon_alert %}
|
||||
|
||||
{% block title %}{{ network_name }} - Messages{% endblock %}
|
||||
{% block title %}Messages - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<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>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -20,63 +23,96 @@
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" data-auto-submit>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="direct" {% if message_type == 'direct' %}selected{% endif %}>Direct</option>
|
||||
<option value="contact" {% if message_type == 'contact' %}selected{% endif %}>Direct</option>
|
||||
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Channel</span>
|
||||
</label>
|
||||
<select name="channel_idx" class="select select-bordered select-sm">
|
||||
<option value="">All Channels</option>
|
||||
{% for i in range(8) %}
|
||||
<option value="{{ i }}" {% if channel_idx == i %}selected{% endif %}>Channel {{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Table -->
|
||||
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<!-- Messages List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for msg in messages %}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="opacity-60">Public</span>
|
||||
{% else %}
|
||||
{% if msg.sender_tag_name or msg.sender_name %}
|
||||
{{ msg.sender_tag_name or msg.sender_name }}
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ msg.received_at|localtime_short }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm hover:opacity-70" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-sm hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 break-words whitespace-pre-wrap">{{ msg.text or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No messages found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Messages Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>From/Channel</th>
|
||||
<th>From</th>
|
||||
<th>Message</th>
|
||||
<th>Receiver</th>
|
||||
<th>SNR</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr class="hover align-top">
|
||||
<td>
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="badge badge-info badge-sm">Channel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success badge-sm">Direct</span>
|
||||
{% endif %}
|
||||
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
|
||||
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
{{ msg.received_at|localtime }}
|
||||
</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>
|
||||
@@ -87,89 +123,27 @@
|
||||
</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length > 1 %}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
|
||||
{{ msg.receivers|length }} receivers
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
|
||||
{% for recv in msg.receivers %}
|
||||
<li>
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
|
||||
<span class="flex-1">{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}</span>
|
||||
{% if recv.snr is not none %}
|
||||
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(recv.snr) }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
{% for recv in msg.receivers %}
|
||||
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif msg.receivers and msg.receivers|length == 1 %}
|
||||
<a href="/nodes/{{ msg.receivers[0].public_key }}" class="link link-hover">
|
||||
{% if msg.receivers[0].tag_name or msg.receivers[0].name %}
|
||||
<div class="font-medium">{{ msg.receivers[0].tag_name or msg.receivers[0].name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.receivers[0].public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.receivers[0].public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif msg.received_by %}
|
||||
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
|
||||
{% if msg.receiver_tag_name or msg.receiver_name %}
|
||||
<div class="font-medium">{{ msg.receiver_tag_name or msg.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center whitespace-nowrap">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
<td colspan="5" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"message_type": message_type, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Network Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Overview</h1>
|
||||
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<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>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Advertisements</div>
|
||||
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages (7 days) -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
|
||||
<div class="stat-desc">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Node Count Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Total Nodes
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Over time (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="nodeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="advertChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Chart -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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
|
||||
</h2>
|
||||
<p class="text-xs opacity-70">Per day (last 7 days)</p>
|
||||
<div class="h-32">
|
||||
<canvas id="messageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Recent Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
Recent Advertisements
|
||||
</h2>
|
||||
{% if stats.recent_advertisements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad in stats.recent_advertisements %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
|
||||
<div class="font-medium">{{ ad.friendly_name or ad.name or ad.public_key[:12] + '...' }}</div>
|
||||
</a>
|
||||
{% if ad.friendly_name or ad.name %}
|
||||
<div class="text-xs opacity-50 font-mono">{{ ad.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif ad.adv_type %}
|
||||
<span title="{{ ad.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right text-sm opacity-70">{{ ad.received_at.split('T')[1][:8] if ad.received_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">No advertisements recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Messages -->
|
||||
{% if stats.channel_messages %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
Recent Channel Messages
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for channel, messages in stats.channel_messages.items() %}
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
|
||||
<span class="badge badge-info badge-sm">CH{{ channel }}</span>
|
||||
Channel {{ channel }}
|
||||
</h3>
|
||||
<div class="space-y-1 pl-2 border-l-2 border-base-300">
|
||||
{% for msg in messages %}
|
||||
<div class="text-sm">
|
||||
<span class="text-xs opacity-50">{{ msg.received_at.split('T')[1][:5] if msg.received_at else '' }}</span>
|
||||
<span class="break-words" style="white-space: pre-wrap;">{{ msg.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const advertData = {{ advert_activity_json | safe }};
|
||||
const messageData = {{ message_activity_json | safe }};
|
||||
const nodeData = {{ node_count_json | safe }};
|
||||
|
||||
// Common chart options
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'oklch(0.25 0 0)',
|
||||
titleColor: 'oklch(0.9 0 0)',
|
||||
bodyColor: 'oklch(0.9 0 0)',
|
||||
borderColor: 'oklch(0.4 0 0)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
|
||||
ticks: { color: 'oklch(0.7 0 0)', maxRotation: 45, minRotation: 45 }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
|
||||
ticks: { color: 'oklch(0.7 0 0)', precision: 0 }
|
||||
}
|
||||
},
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false }
|
||||
};
|
||||
|
||||
// Helper to format dates
|
||||
function formatLabels(data) {
|
||||
return data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
}
|
||||
|
||||
// Advertisements chart (secondary color - pink/magenta)
|
||||
const advertCtx = document.getElementById('advertChart');
|
||||
if (advertCtx && advertData.data && advertData.data.length > 0) {
|
||||
new Chart(advertCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(advertData.data),
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Messages chart (accent color - teal/cyan)
|
||||
const messageCtx = document.getElementById('messageChart');
|
||||
if (messageCtx && messageData.data && messageData.data.length > 0) {
|
||||
new Chart(messageCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(messageData.data),
|
||||
datasets: [{
|
||||
label: 'Messages',
|
||||
data: messageData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.75 0.18 180)',
|
||||
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Node count chart (primary color - purple/blue)
|
||||
const nodeCtx = document.getElementById('nodeChart');
|
||||
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
|
||||
new Chart(nodeCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatLabels(nodeData.data),
|
||||
datasets: [{
|
||||
label: 'Total Nodes',
|
||||
data: nodeData.data.map(d => d.count),
|
||||
borderColor: 'oklch(0.65 0.24 265)',
|
||||
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: commonOptions
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,21 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_alert, icon_error %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
{% block title %}{% if node %}{{ node.name or ('Node ' ~ public_key[:8] ~ '...') }} - {{ network_name }}{% else %}Node Not Found - {{ network_name }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#node-map {
|
||||
height: 300px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -39,98 +28,96 @@
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<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>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
{# Get display name from tag or node.name #}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Node Info Card -->
|
||||
{% set display_name = ns.tag_name or node.name or 'Unnamed Node' %}
|
||||
|
||||
{# Get coordinates from node model first, then fall back to tags (bug fix) #}
|
||||
{% set ns_coords = namespace(lat=node.lat, lon=node.lon) %}
|
||||
{% if not ns_coords.lat or not ns_coords.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_coords.lat %}
|
||||
{% set ns_coords.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_coords.lon %}
|
||||
{% set ns_coords.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set has_coords = ns_coords.lat is not none and ns_coords.lon is not none %}
|
||||
|
||||
{# Node type emoji #}
|
||||
{% set type_emoji = '📍' %}
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
{% set type_emoji = '💬' %}
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
{% set type_emoji = '📡' %}
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
{% set type_emoji = '🪧' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Header -->
|
||||
<h1 class="text-3xl font-bold mb-6">
|
||||
<span title="{{ node.adv_type or 'Unknown' }}">{{ type_emoji }}</span>
|
||||
{{ display_name }}
|
||||
</h1>
|
||||
|
||||
<!-- Node Hero Panel -->
|
||||
{% if has_coords %}
|
||||
<div class="relative rounded-box overflow-hidden mb-6 shadow-xl" style="height: 180px;">
|
||||
<!-- Map container (non-interactive background) -->
|
||||
<div id="header-map" class="absolute inset-0 z-0"></div>
|
||||
|
||||
<!-- QR code overlay (right side, fills height) -->
|
||||
<div class="relative z-20 h-full p-3 flex items-center justify-end">
|
||||
<div id="qr-code" class="bg-white p-2 rounded shadow-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- QR Code Card (no map) -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body flex-row items-center gap-4">
|
||||
<div id="qr-code" class="bg-white p-1 rounded"></div>
|
||||
<p class="text-sm opacity-70">Scan to add as contact</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Details Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public Key -->
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Tags and Map Grid -->
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
|
||||
<!-- Tags -->
|
||||
{% if node.tags %}
|
||||
<!-- First/Last Seen and Location -->
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span class="opacity-70">First seen:</span>
|
||||
{{ node.first_seen|localtime }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
|
||||
<div id="node-map" class="mb-2"></div>
|
||||
<div class="text-sm opacity-70">
|
||||
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
|
||||
</div>
|
||||
<span class="opacity-70">Last seen:</span>
|
||||
{{ node.last_seen|localtime }}
|
||||
</div>
|
||||
{% if has_coords %}
|
||||
<div>
|
||||
<span class="opacity-70">Location:</span>
|
||||
{{ ns_coords.lat }}, {{ ns_coords.lon }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -155,7 +142,7 @@
|
||||
<tbody>
|
||||
{% for adv in advertisements %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
|
||||
<td class="text-xs whitespace-nowrap">{{ adv.received_at|localtime }}</td>
|
||||
<td>
|
||||
{% if adv.adv_type and adv.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
@@ -194,52 +181,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Telemetry -->
|
||||
<!-- Tags -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Telemetry</h2>
|
||||
{% if telemetry %}
|
||||
<h2 class="card-title">Tags</h2>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Data</th>
|
||||
<th>Received By</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tel in telemetry %}
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
|
||||
<td class="text-xs font-mono">
|
||||
{% if tel.parsed_data %}
|
||||
{{ tel.parsed_data | tojson }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tel.received_by %}
|
||||
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
|
||||
{% if tel.receiver_tag_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No telemetry recorded.</p>
|
||||
<p class="opacity-70">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,9 +220,7 @@
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-error">
|
||||
<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>
|
||||
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Node not found: {{ public_key }}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
|
||||
@@ -258,64 +229,52 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% set ns_qr = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_qr.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<script>
|
||||
window.qrCodeConfig = {
|
||||
name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }},
|
||||
publicKey: {{ node.public_key | tojson }},
|
||||
advType: {{ (node.adv_type or '') | tojson }},
|
||||
size: 140
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/qrcode-init.js') }}"></script>
|
||||
|
||||
{# Get coordinates from node model first, then fall back to tags #}
|
||||
{% set ns_map = namespace(lat=node.lat, lon=node.lon, name=none) %}
|
||||
{% if not ns_map.lat or not ns_map.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_map.lat %}
|
||||
{% set ns_map.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_map.lon %}
|
||||
{% set ns_map.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
// Initialize map centered on the node's location
|
||||
const nodeLat = {{ ns_map.lat }};
|
||||
const nodeLon = {{ ns_map.lon }};
|
||||
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
|
||||
const nodeType = {{ (node.adv_type or '') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
|
||||
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(type) {
|
||||
const normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
const emoji = getNodeEmoji(nodeType);
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
|
||||
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
window.nodeMapConfig = {
|
||||
elementId: 'header-map',
|
||||
lat: {{ ns_map.lat }},
|
||||
lon: {{ ns_map.lon }},
|
||||
name: {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }},
|
||||
type: {{ (node.adv_type or '') | tojson }},
|
||||
interactive: false,
|
||||
zoom: 14,
|
||||
offsetX: 0.33
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/map-node.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
{% from "macros/icons.html" import icon_alert %}
|
||||
|
||||
{% block title %}{{ network_name }} - Nodes{% endblock %}
|
||||
{% block title %}Nodes - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if timezone and timezone != 'UTC' %}<span class="text-sm opacity-60">{{ timezone }}</span>{% endif %}
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<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>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -20,7 +23,7 @@
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
|
||||
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" data-auto-submit>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
@@ -38,19 +41,83 @@
|
||||
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table -->
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<!-- Nodes List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen|localdate }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 justify-end mt-1">
|
||||
{% for tag in node.tags[:2] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 2 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No nodes found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
@@ -65,31 +132,21 @@
|
||||
{% endfor %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
|
||||
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div>
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if node.adv_type and node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type and node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% elif node.adv_type %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
{{ node.last_seen|localtime }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
@@ -111,39 +168,12 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
15
src/meshcore_hub/web/templates/page.html
Normal file
15
src/meshcore_hub/web/templates/page.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block meta_description %}{{ page.title }} - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body prose prose-lg max-w-none">
|
||||
{{ page.content_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -122,9 +122,9 @@ class TestWebDashboard:
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
def test_network_page(self, web_client: httpx.Client) -> None:
|
||||
"""Test network overview page loads."""
|
||||
response = web_client.get("/network")
|
||||
def test_dashboard_page(self, web_client: httpx.Client) -> None:
|
||||
"""Test dashboard page loads."""
|
||||
response = web_client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Base,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
@@ -264,3 +265,147 @@ def sample_trace_path(api_db_session):
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_member(api_db_session):
|
||||
"""Create a sample member in the database."""
|
||||
member = Member(
|
||||
member_id="alice",
|
||||
name="Alice Smith",
|
||||
callsign="W1ABC",
|
||||
role="Admin",
|
||||
description="Network administrator",
|
||||
contact="alice@example.com",
|
||||
)
|
||||
api_db_session.add(member)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(member)
|
||||
return member
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def receiver_node(api_db_session):
|
||||
"""Create a receiver node in the database."""
|
||||
node = Node(
|
||||
public_key="receiver123receiver123receiver12",
|
||||
name="Receiver Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a message with a receiver node."""
|
||||
message = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
pubkey_prefix="xyz789",
|
||||
text="Channel message with receiver",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_advertisement_with_receiver(api_db_session, sample_node, receiver_node):
|
||||
"""Create an advertisement with source and receiver nodes."""
|
||||
advert = Advertisement(
|
||||
public_key=sample_node.public_key,
|
||||
name="SourceNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
node_id=sample_node.id,
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_telemetry_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a telemetry record with a receiver node."""
|
||||
telemetry = Telemetry(
|
||||
node_public_key="xyz789xyz789xyz789xyz789xyz789xy",
|
||||
parsed_data={"battery_level": 50.0},
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(telemetry)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(telemetry)
|
||||
return telemetry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_trace_path_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a trace path with a receiver node."""
|
||||
trace = TracePath(
|
||||
initiator_tag=99999,
|
||||
path_hashes=["aaa111", "bbb222"],
|
||||
hop_count=2,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
receiver_node_id=receiver_node.id,
|
||||
)
|
||||
api_db_session.add(trace)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_with_name_tag(api_db_session):
|
||||
"""Create a node with a name tag for search testing."""
|
||||
node = Node(
|
||||
public_key="searchable123searchable123searc",
|
||||
name="Original Name",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="name",
|
||||
value="Friendly Search Name",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_with_member_tag(api_db_session):
|
||||
"""Create a node with a member_id tag for filter testing."""
|
||||
node = Node(
|
||||
public_key="member123member123member123membe",
|
||||
name="Member Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="member_id",
|
||||
value="alice",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for advertisement API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListAdvertisements:
|
||||
"""Tests for GET /advertisements endpoint."""
|
||||
@@ -55,3 +57,120 @@ class TestGetAdvertisement:
|
||||
"""Test getting a non-existent advertisement."""
|
||||
response = client_no_auth.get("/api/v1/advertisements/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListAdvertisementsFilters:
|
||||
"""Tests for advertisement list query filters."""
|
||||
|
||||
def test_filter_by_search_public_key(self, client_no_auth, sample_advertisement):
|
||||
"""Test filtering advertisements by public key search."""
|
||||
# Partial public key match
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=zzz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_search_name(self, client_no_auth, sample_advertisement):
|
||||
"""Test filtering advertisements by name search."""
|
||||
response = client_no_auth.get("/api/v1/advertisements?search=TestNode")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_advertisement,
|
||||
sample_advertisement_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering advertisements by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_member_id(
|
||||
self, client_no_auth, api_db_session, sample_node_with_member_tag
|
||||
):
|
||||
"""Test filtering advertisements by member_id tag."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
# Create an advertisement for the node with member tag
|
||||
advert = Advertisement(
|
||||
public_key=sample_node_with_member_tag.public_key,
|
||||
name="Member Node Ad",
|
||||
adv_type="CHAT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
node_id=sample_node_with_member_tag.id,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter by member_id
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by since timestamp."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old advertisement
|
||||
old_advert = Advertisement(
|
||||
public_key="old123old123old123old123old123ol",
|
||||
name="Old Advertisement",
|
||||
adv_type="CLIENT",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old advertisement
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/advertisements?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by until timestamp."""
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old advertisement
|
||||
old_advert = Advertisement(
|
||||
public_key="until123until123until123until12",
|
||||
name="Old Advertisement Until",
|
||||
adv_type="CLIENT",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old advertisement
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/advertisements?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"""Tests for dashboard API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node
|
||||
|
||||
|
||||
class TestDashboardStats:
|
||||
"""Tests for GET /dashboard/stats endpoint."""
|
||||
@@ -63,6 +69,21 @@ class TestDashboardHtml:
|
||||
class TestDashboardActivity:
|
||||
"""Tests for GET /dashboard/activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_advertisement(self, api_db_session):
|
||||
"""Create an advertisement from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
advert = Advertisement(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="TestNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
def test_get_activity_empty(self, client_no_auth):
|
||||
"""Test getting activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
@@ -91,8 +112,12 @@ class TestDashboardActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_activity_with_data(self, client_no_auth, sample_advertisement):
|
||||
"""Test getting activity with advertisement in database."""
|
||||
def test_get_activity_with_data(self, client_no_auth, past_advertisement):
|
||||
"""Test getting activity with advertisement in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -104,6 +129,21 @@ class TestDashboardActivity:
|
||||
class TestMessageActivity:
|
||||
"""Tests for GET /dashboard/message-activity endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_message(self, api_db_session):
|
||||
"""Create a message from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
message = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="abc123",
|
||||
text="Hello World",
|
||||
received_at=yesterday,
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
def test_get_message_activity_empty(self, client_no_auth):
|
||||
"""Test getting message activity with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
@@ -132,8 +172,12 @@ class TestMessageActivity:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_message_activity_with_data(self, client_no_auth, sample_message):
|
||||
"""Test getting message activity with message in database."""
|
||||
def test_get_message_activity_with_data(self, client_no_auth, past_message):
|
||||
"""Test getting message activity with message in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/message-activity")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -145,6 +189,23 @@ class TestMessageActivity:
|
||||
class TestNodeCountHistory:
|
||||
"""Tests for GET /dashboard/node-count endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def past_node(self, api_db_session):
|
||||
"""Create a node from yesterday (since today is excluded)."""
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
node = Node(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="Test Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=yesterday,
|
||||
last_seen=yesterday,
|
||||
created_at=yesterday,
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
def test_get_node_count_empty(self, client_no_auth):
|
||||
"""Test getting node count with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
@@ -173,8 +234,12 @@ class TestNodeCountHistory:
|
||||
assert data["days"] == 90
|
||||
assert len(data["data"]) == 90
|
||||
|
||||
def test_get_node_count_with_data(self, client_no_auth, sample_node):
|
||||
"""Test getting node count with node in database."""
|
||||
def test_get_node_count_with_data(self, client_no_auth, past_node):
|
||||
"""Test getting node count with node in database.
|
||||
|
||||
Note: Activity endpoints exclude today's data to avoid showing
|
||||
incomplete stats early in the day.
|
||||
"""
|
||||
response = client_no_auth.get("/api/v1/dashboard/node-count")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
285
tests/test_api/test_members.py
Normal file
285
tests/test_api/test_members.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Tests for member API routes."""
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
"""Tests for GET /members endpoint."""
|
||||
|
||||
def test_list_members_empty(self, client_no_auth):
|
||||
"""Test listing members when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_members_with_data(self, client_no_auth, sample_member):
|
||||
"""Test listing members with data in database."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["member_id"] == sample_member.member_id
|
||||
assert data["items"][0]["name"] == sample_member.name
|
||||
assert data["items"][0]["callsign"] == sample_member.callsign
|
||||
|
||||
def test_list_members_pagination(self, client_no_auth, sample_member):
|
||||
"""Test member list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/members?limit=25&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 25
|
||||
assert data["offset"] == 10
|
||||
|
||||
def test_list_members_requires_read_auth(self, client_with_auth):
|
||||
"""Test listing members requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get("/api/v1/members")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/members",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetMember:
|
||||
"""Tests for GET /members/{member_id} endpoint."""
|
||||
|
||||
def test_get_member_success(self, client_no_auth, sample_member):
|
||||
"""Test getting a specific member."""
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["name"] == sample_member.name
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
assert data["role"] == sample_member.role
|
||||
assert data["description"] == sample_member.description
|
||||
assert data["contact"] == sample_member.contact
|
||||
|
||||
def test_get_member_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent member."""
|
||||
response = client_no_auth.get("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_get_member_requires_read_auth(self, client_with_auth, sample_member):
|
||||
"""Test getting a member requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestCreateMember:
|
||||
"""Tests for POST /members endpoint."""
|
||||
|
||||
def test_create_member_success(self, client_no_auth):
|
||||
"""Test creating a new member."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "bob",
|
||||
"name": "Bob Jones",
|
||||
"callsign": "W2XYZ",
|
||||
"role": "Member",
|
||||
"description": "Regular member",
|
||||
"contact": "bob@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "bob"
|
||||
assert data["name"] == "Bob Jones"
|
||||
assert data["callsign"] == "W2XYZ"
|
||||
assert data["role"] == "Member"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_member_minimal(self, client_no_auth):
|
||||
"""Test creating a member with only required fields."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "charlie",
|
||||
"name": "Charlie Brown",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "charlie"
|
||||
assert data["name"] == "Charlie Brown"
|
||||
assert data["callsign"] is None
|
||||
assert data["role"] is None
|
||||
|
||||
def test_create_member_duplicate_member_id(self, client_no_auth, sample_member):
|
||||
"""Test creating a member with duplicate member_id fails."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": sample_member.member_id, # "alice" already exists
|
||||
"name": "Another Alice",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_create_member_requires_admin_auth(self, client_with_auth):
|
||||
"""Test creating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestUpdateMember:
|
||||
"""Tests for PUT /members/{member_id} endpoint."""
|
||||
|
||||
def test_update_member_success(self, client_no_auth, sample_member):
|
||||
"""Test updating a member."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={
|
||||
"name": "Alice Johnson",
|
||||
"role": "Super Admin",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Alice Johnson"
|
||||
assert data["role"] == "Super Admin"
|
||||
# Unchanged fields should remain
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
|
||||
def test_update_member_change_member_id(self, client_no_auth, sample_member):
|
||||
"""Test updating member_id."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "alice2"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == "alice2"
|
||||
|
||||
def test_update_member_member_id_collision(
|
||||
self, client_no_auth, api_db_session, sample_member
|
||||
):
|
||||
"""Test updating member_id to one that already exists fails."""
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
# Create another member
|
||||
other_member = Member(
|
||||
member_id="bob",
|
||||
name="Bob",
|
||||
)
|
||||
api_db_session.add(other_member)
|
||||
api_db_session.commit()
|
||||
|
||||
# Try to change alice's member_id to "bob"
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "bob"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_not_found(self, client_no_auth):
|
||||
"""Test updating a non-existent member."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/members/nonexistent-id",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test updating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestDeleteMember:
|
||||
"""Tests for DELETE /members/{member_id} endpoint."""
|
||||
|
||||
def test_delete_member_success(self, client_no_auth, sample_member):
|
||||
"""Test deleting a member."""
|
||||
response = client_no_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's deleted
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_member_not_found(self, client_no_auth):
|
||||
"""Test deleting a non-existent member."""
|
||||
response = client_no_auth.delete("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test deleting a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for message API routes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class TestListMessages:
|
||||
"""Tests for GET /messages endpoint."""
|
||||
@@ -57,3 +59,127 @@ class TestGetMessage:
|
||||
"""Test getting a non-existent message."""
|
||||
response = client_no_auth.get("/api/v1/messages/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListMessagesFilters:
|
||||
"""Tests for message list query filters."""
|
||||
|
||||
def test_filter_by_pubkey_prefix(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by pubkey_prefix."""
|
||||
# Match
|
||||
response = client_no_auth.get("/api/v1/messages?pubkey_prefix=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/messages?pubkey_prefix=xyz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_channel_idx(
|
||||
self, client_no_auth, sample_message, sample_message_with_receiver
|
||||
):
|
||||
"""Test filtering messages by channel_idx."""
|
||||
# Channel 1 should match sample_message_with_receiver
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["channel_idx"] == 1
|
||||
|
||||
# Channel 0 should return no results
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_message,
|
||||
sample_message_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering messages by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/messages?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == sample_message_with_receiver.text
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering messages by since timestamp."""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshcore_hub.common.models import Message
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old message
|
||||
old_msg = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="old123",
|
||||
text="Old message",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_msg)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old message
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/messages?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering messages by until timestamp."""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshcore_hub.common.models import Message
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create an old message
|
||||
old_msg = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="old456",
|
||||
text="Old message for until",
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_msg)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old message
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/messages?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == "Old message for until"
|
||||
|
||||
def test_filter_by_search(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by text search."""
|
||||
# Match
|
||||
response = client_no_auth.get("/api/v1/messages?search=Hello")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# Case insensitive match
|
||||
response = client_no_auth.get("/api/v1/messages?search=hello")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/messages?search=nonexistent")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
@@ -57,6 +57,66 @@ class TestListNodes:
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestListNodesFilters:
|
||||
"""Tests for node list query filters."""
|
||||
|
||||
def test_filter_by_search_public_key(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by public key search."""
|
||||
# Partial public key match
|
||||
response = client_no_auth.get("/api/v1/nodes?search=abc123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?search=zzz999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_search_node_name(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by node name search."""
|
||||
response = client_no_auth.get("/api/v1/nodes?search=Test%20Node")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_search_name_tag(self, client_no_auth, sample_node_with_name_tag):
|
||||
"""Test filtering nodes by name tag search."""
|
||||
response = client_no_auth.get("/api/v1/nodes?search=Friendly%20Search")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_adv_type(self, client_no_auth, sample_node):
|
||||
"""Test filtering nodes by advertisement type."""
|
||||
# Match REPEATER
|
||||
response = client_no_auth.get("/api/v1/nodes?adv_type=REPEATER")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?adv_type=CLIENT")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag):
|
||||
"""Test filtering nodes by member_id tag."""
|
||||
# Match alice
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
@@ -86,6 +146,54 @@ class TestGetNode:
|
||||
response = client_no_auth.get("/api/v1/nodes/nonexistent123")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_node_by_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by public key prefix."""
|
||||
prefix = sample_node.public_key[:8] # First 8 chars
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_by_single_char_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by single character prefix."""
|
||||
prefix = sample_node.public_key[0]
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_prefix_returns_first_alphabetically(
|
||||
self, client_no_auth, api_db_session
|
||||
):
|
||||
"""Test that prefix match returns first node alphabetically."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
# Create two nodes with same prefix but different suffixes
|
||||
# abc... should come before abd...
|
||||
node_a = Node(
|
||||
public_key="abc0000000000000000000000000000000000000000000000000000000000000",
|
||||
name="Node A",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
node_b = Node(
|
||||
public_key="abc1111111111111111111111111111111111111111111111111111111111111",
|
||||
name="Node B",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node_a)
|
||||
api_db_session.add(node_b)
|
||||
api_db_session.commit()
|
||||
|
||||
# Request with prefix should return first alphabetically
|
||||
response = client_no_auth.get("/api/v1/nodes/prefix/abc")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == node_a.public_key
|
||||
|
||||
|
||||
class TestNodeTags:
|
||||
"""Tests for node tag endpoints."""
|
||||
@@ -158,3 +266,166 @@ class TestNodeTags:
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
|
||||
|
||||
class TestMoveNodeTag:
|
||||
"""Tests for PUT /nodes/{public_key}/tags/{key}/move endpoint."""
|
||||
|
||||
# 64-character public key for testing
|
||||
DEST_PUBLIC_KEY = "xyz789xyz789xyz789xyz789xyz789xyabc123abc123abc123abc123abc123ab"
|
||||
|
||||
def test_move_node_tag_success(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test successfully moving a tag to another node."""
|
||||
from meshcore_hub.common.models import Node
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create a second node with 64-char public key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == sample_node_tag.key
|
||||
assert data["value"] == sample_node_tag.value
|
||||
|
||||
# Verify tag is no longer on original node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verify tag is now on new node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{second_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_move_node_tag_source_not_found(self, client_no_auth):
|
||||
"""Test moving a tag from a non-existent node."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/nodes/nonexistent123/tags/somekey/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Source node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_tag_not_found(self, client_no_auth, sample_node):
|
||||
"""Test moving a non-existent tag."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/nonexistent/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Tag not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_dest_not_found(
|
||||
self, client_no_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag to a non-existent destination node."""
|
||||
# 64-character nonexistent public key
|
||||
nonexistent_key = (
|
||||
"1111111111111111111111111111111122222222222222222222222222222222"
|
||||
)
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": nonexistent_key},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Destination node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_conflict(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag when destination already has that key."""
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create second node with same tag key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
# Add the same tag key to second node
|
||||
existing_tag = NodeTag(
|
||||
node_id=second_node.id,
|
||||
key=sample_node_tag.key, # Same key
|
||||
value="different value",
|
||||
)
|
||||
api_db_session.add(existing_tag)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already exists on destination" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_requires_admin(
|
||||
self, client_with_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test that move operation requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_move_node_tag_same_node(self, client_no_auth, api_db_session):
|
||||
"""Test moving a tag to the same node returns 400."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
|
||||
# Create node with 64-char public key
|
||||
full_key = "abc123def456abc123def456abc123deabc123def456abc123def456abc123de"
|
||||
node = Node(
|
||||
public_key=full_key,
|
||||
name="Test Node 64",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
# Create tag
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="test_tag",
|
||||
value="test_value",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{full_key}/tags/test_tag/move",
|
||||
json={"new_public_key": full_key},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "same" in response.json()["detail"].lower()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for telemetry API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListTelemetry:
|
||||
"""Tests for GET /telemetry endpoint."""
|
||||
@@ -51,3 +53,68 @@ class TestGetTelemetry:
|
||||
"""Test getting a non-existent telemetry record."""
|
||||
response = client_no_auth.get("/api/v1/telemetry/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListTelemetryFilters:
|
||||
"""Tests for telemetry list query filters."""
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_telemetry,
|
||||
sample_telemetry_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering telemetry by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/telemetry?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering telemetry by since timestamp."""
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old telemetry
|
||||
old_telemetry = Telemetry(
|
||||
node_public_key="old123old123old123old123old123ol",
|
||||
parsed_data={"battery_level": 10.0},
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_telemetry)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old telemetry
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/telemetry?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering telemetry by until timestamp."""
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old telemetry
|
||||
old_telemetry = Telemetry(
|
||||
node_public_key="until123until123until123until12",
|
||||
parsed_data={"battery_level": 20.0},
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_telemetry)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old telemetry
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/telemetry?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for trace path API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
class TestListTracePaths:
|
||||
"""Tests for GET /trace-paths endpoint."""
|
||||
@@ -37,3 +39,70 @@ class TestGetTracePath:
|
||||
"""Test getting a non-existent trace path."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListTracePathsFilters:
|
||||
"""Tests for trace path list query filters."""
|
||||
|
||||
def test_filter_by_received_by(
|
||||
self,
|
||||
client_no_auth,
|
||||
sample_trace_path,
|
||||
sample_trace_path_with_receiver,
|
||||
receiver_node,
|
||||
):
|
||||
"""Test filtering trace paths by receiver node."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/trace-paths?received_by={receiver_node.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering trace paths by since timestamp."""
|
||||
from meshcore_hub.common.models import TracePath
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old trace path
|
||||
old_trace = TracePath(
|
||||
initiator_tag=11111,
|
||||
path_hashes=["old1", "old2"],
|
||||
hop_count=2,
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_trace)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter since yesterday - should not include old trace path
|
||||
since = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths?since={since}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_until(self, client_no_auth, api_db_session):
|
||||
"""Test filtering trace paths by until timestamp."""
|
||||
from meshcore_hub.common.models import TracePath
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=7)
|
||||
|
||||
# Create old trace path
|
||||
old_trace = TracePath(
|
||||
initiator_tag=22222,
|
||||
path_hashes=["until1", "until2"],
|
||||
hop_count=2,
|
||||
received_at=old_time,
|
||||
)
|
||||
api_db_session.add(old_trace)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter until 5 days ago - should include old trace path
|
||||
until = (now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths?until={until}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestLoadTagsFile:
|
||||
"""Test loading file with full format (value and type)."""
|
||||
data = {
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
|
||||
"location": {"value": "52.0,1.0", "type": "coordinate"},
|
||||
"is_active": {"value": "true", "type": "boolean"},
|
||||
"altitude": {"value": "150", "type": "number"},
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class TestLoadTagsFile:
|
||||
|
||||
result = load_tags_file(f.name)
|
||||
key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
assert result[key]["location"]["type"] == "coordinate"
|
||||
assert result[key]["is_active"]["type"] == "boolean"
|
||||
assert result[key]["altitude"]["type"] == "number"
|
||||
|
||||
Path(f.name).unlink()
|
||||
|
||||
@@ -6,26 +6,12 @@ from meshcore_hub.common.config import (
|
||||
CollectorSettings,
|
||||
APISettings,
|
||||
WebSettings,
|
||||
LogLevel,
|
||||
InterfaceMode,
|
||||
)
|
||||
|
||||
|
||||
class TestCommonSettings:
|
||||
"""Tests for CommonSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CommonSettings(_env_file=None)
|
||||
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.log_level == LogLevel.INFO
|
||||
assert settings.mqtt_host == "localhost"
|
||||
assert settings.mqtt_port == 1883
|
||||
assert settings.mqtt_username is None
|
||||
assert settings.mqtt_password is None
|
||||
assert settings.mqtt_prefix == "meshcore"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test custom DATA_HOME setting."""
|
||||
settings = CommonSettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -36,37 +22,19 @@ class TestCommonSettings:
|
||||
class TestInterfaceSettings:
|
||||
"""Tests for InterfaceSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = InterfaceSettings(_env_file=None)
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom setting values."""
|
||||
settings = InterfaceSettings(
|
||||
_env_file=None, serial_port="/dev/ttyACM0", serial_baud=9600
|
||||
)
|
||||
|
||||
assert settings.interface_mode == InterfaceMode.RECEIVER
|
||||
assert settings.serial_port == "/dev/ttyUSB0"
|
||||
assert settings.serial_baud == 115200
|
||||
assert settings.mock_device is False
|
||||
assert settings.serial_port == "/dev/ttyACM0"
|
||||
assert settings.serial_baud == 9600
|
||||
|
||||
|
||||
class TestCollectorSettings:
|
||||
"""Tests for CollectorSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CollectorSettings(_env_file=None)
|
||||
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.collector_data_dir == "data/collector"
|
||||
|
||||
# seed_home defaults to ./seed (normalized to "seed")
|
||||
assert settings.seed_home == "./seed"
|
||||
assert settings.effective_seed_home == "seed"
|
||||
# node_tags_file and members_file are derived from effective_seed_home
|
||||
assert settings.node_tags_file == "seed/node_tags.yaml"
|
||||
assert settings.members_file == "seed/members.yaml"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = CollectorSettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -76,10 +44,6 @@ class TestCollectorSettings:
|
||||
== "sqlite:////custom/data/collector/meshcore.db"
|
||||
)
|
||||
assert settings.collector_data_dir == "/custom/data/collector"
|
||||
# seed_home is independent of data_home
|
||||
assert settings.effective_seed_home == "seed"
|
||||
assert settings.node_tags_file == "seed/node_tags.yaml"
|
||||
assert settings.members_file == "seed/members.yaml"
|
||||
|
||||
def test_explicit_database_url_overrides(self) -> None:
|
||||
"""Test that explicit database_url overrides the default."""
|
||||
@@ -103,19 +67,6 @@ class TestCollectorSettings:
|
||||
class TestAPISettings:
|
||||
"""Tests for APISettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = APISettings(_env_file=None)
|
||||
|
||||
assert settings.api_host == "0.0.0.0"
|
||||
assert settings.api_port == 8000
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.api_read_key is None
|
||||
assert settings.api_admin_key is None
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective database path."""
|
||||
settings = APISettings(_env_file=None, data_home="/custom/data")
|
||||
@@ -136,17 +87,6 @@ class TestAPISettings:
|
||||
class TestWebSettings:
|
||||
"""Tests for WebSettings."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = WebSettings(_env_file=None)
|
||||
|
||||
assert settings.web_host == "0.0.0.0"
|
||||
assert settings.web_port == 8080
|
||||
assert settings.api_base_url == "http://localhost:8000"
|
||||
assert settings.network_name == "MeshCore Network"
|
||||
# Path normalizes ./data to data
|
||||
assert settings.web_data_dir == "data/web"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = WebSettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
@@ -55,14 +55,14 @@ class TestNodeModel:
|
||||
def test_node_tags_relationship(self, db_session) -> None:
|
||||
"""Test node-tag relationship."""
|
||||
node = Node(public_key="b" * 64, name="Tagged Node")
|
||||
tag = NodeTag(key="location", value="51.5,-0.1", value_type="coordinate")
|
||||
tag = NodeTag(key="altitude", value="150", value_type="number")
|
||||
node.tags.append(tag)
|
||||
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
assert len(node.tags) == 1
|
||||
assert node.tags[0].key == "location"
|
||||
assert node.tags[0].key == "altitude"
|
||||
|
||||
|
||||
class TestMessageModel:
|
||||
|
||||
@@ -40,7 +40,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
@@ -48,7 +48,7 @@ class MockHttpClient:
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456abc123def456abc123def456ab",
|
||||
"public_key": "def456abc123def456abc123def456abc123def456abc123def456abc123def4",
|
||||
"name": "Node Two",
|
||||
"adv_type": "CLIENT",
|
||||
"last_seen": "2024-01-01T11:00:00Z",
|
||||
@@ -62,12 +62,14 @@ class MockHttpClient:
|
||||
},
|
||||
}
|
||||
|
||||
# Default single node response
|
||||
self._responses["GET:/api/v1/nodes/abc123def456abc123def456abc123de"] = {
|
||||
# Default single node response (exact match)
|
||||
self._responses[
|
||||
"GET:/api/v1/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
||||
] = {
|
||||
"status_code": 200,
|
||||
"json": {
|
||||
"id": "node-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
@@ -110,7 +112,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "adv-1",
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"received_at": "2024-01-01T12:00:00Z",
|
||||
@@ -127,7 +129,7 @@ class MockHttpClient:
|
||||
"items": [
|
||||
{
|
||||
"id": "tel-1",
|
||||
"node_public_key": "abc123def456abc123def456abc123de",
|
||||
"node_public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"parsed_data": {"battery_level": 85.5},
|
||||
"received_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
@@ -255,6 +257,18 @@ class MockHttpClient:
|
||||
key = f"POST:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def put(
|
||||
self, path: str, json: dict | None = None, params: dict | None = None
|
||||
) -> Response:
|
||||
"""Mock PUT request."""
|
||||
key = f"PUT:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def delete(self, path: str, params: dict | None = None) -> Response:
|
||||
"""Mock DELETE request."""
|
||||
key = f"DELETE:{path}"
|
||||
return self._create_response(key)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Mock close method."""
|
||||
pass
|
||||
|
||||
426
tests/test_web/test_admin.py
Normal file
426
tests/test_web/test_admin.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""Tests for admin web routes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
from .conftest import MockHttpClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client_admin() -> MockHttpClient:
|
||||
"""Create a mock HTTP client for admin tests."""
|
||||
client = MockHttpClient()
|
||||
|
||||
# Mock the nodes API response for admin dropdown
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"name": "Node One",
|
||||
"adv_type": "REPEATER",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
"name": "Node Two",
|
||||
"adv_type": "CHAT",
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T11:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock node tags response
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
200,
|
||||
[
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "location",
|
||||
"value": "building-a",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Mock create tag response
|
||||
client.set_response(
|
||||
"POST",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags",
|
||||
201,
|
||||
{
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock update tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock move tag response
|
||||
client.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment/move",
|
||||
200,
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "production",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Mock delete tag response
|
||||
client.set_response(
|
||||
"DELETE",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/environment",
|
||||
204,
|
||||
None,
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin enabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
network_city="Test City",
|
||||
network_country="Test Country",
|
||||
network_radio_config="Test Radio Config",
|
||||
network_contact_email="test@example.com",
|
||||
admin_enabled=True,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app_disabled(mock_http_client_admin: MockHttpClient) -> Any:
|
||||
"""Create a web app with admin disabled."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
network_city="Test City",
|
||||
network_country="Test Country",
|
||||
network_radio_config="Test Radio Config",
|
||||
network_contact_email="test@example.com",
|
||||
admin_enabled=False,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_admin
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers() -> dict:
|
||||
"""Authentication headers for admin requests."""
|
||||
return {
|
||||
"X-Forwarded-User": "test-user-id",
|
||||
"X-Forwarded-Email": "test@example.com",
|
||||
"X-Forwarded-Preferred-Username": "testuser",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(admin_app: Any, mock_http_client_admin: MockHttpClient) -> TestClient:
|
||||
"""Create a test client with admin enabled."""
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
return TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_disabled(
|
||||
admin_app_disabled: Any, mock_http_client_admin: MockHttpClient
|
||||
) -> TestClient:
|
||||
"""Create a test client with admin disabled."""
|
||||
admin_app_disabled.state.http_client = mock_http_client_admin
|
||||
return TestClient(admin_app_disabled, raise_server_exceptions=True)
|
||||
|
||||
|
||||
class TestAdminHome:
|
||||
"""Tests for admin home page."""
|
||||
|
||||
def test_admin_home_enabled(self, admin_client, auth_headers):
|
||||
"""Test admin home page when enabled."""
|
||||
response = admin_client.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Admin" in response.text
|
||||
assert "Node Tags" in response.text
|
||||
|
||||
def test_admin_home_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test admin home page when disabled."""
|
||||
response = admin_client_disabled.get("/a/", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_admin_home_unauthenticated(self, admin_client):
|
||||
"""Test admin home page without authentication."""
|
||||
response = admin_client.get("/a/")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
|
||||
|
||||
class TestAdminNodeTags:
|
||||
"""Tests for admin node tags page."""
|
||||
|
||||
def test_node_tags_page_no_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page without selecting a node."""
|
||||
response = admin_client.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
assert "Select a Node" in response.text
|
||||
# Should show node dropdown
|
||||
assert "Node One" in response.text
|
||||
assert "Node Two" in response.text
|
||||
|
||||
def test_node_tags_page_with_selection(self, admin_client, auth_headers):
|
||||
"""Test node tags page with a node selected."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Node Tags" in response.text
|
||||
# Should show the selected node's tags
|
||||
assert "environment" in response.text
|
||||
assert "production" in response.text
|
||||
assert "location" in response.text
|
||||
assert "building-a" in response.text
|
||||
|
||||
def test_node_tags_page_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test node tags page when admin is disabled."""
|
||||
response = admin_client_disabled.get("/a/node-tags", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_node_tags_page_with_message(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays success message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&message=Tag%20created%20successfully",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag created successfully" in response.text
|
||||
|
||||
def test_node_tags_page_with_error(self, admin_client, auth_headers):
|
||||
"""Test node tags page displays error message."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de"
|
||||
"&error=Tag%20already%20exists",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Tag already exists" in response.text
|
||||
|
||||
def test_node_tags_page_unauthenticated(self, admin_client):
|
||||
"""Test node tags page without authentication."""
|
||||
response = admin_client.get("/a/node-tags")
|
||||
assert response.status_code == 403
|
||||
assert "Access Denied" in response.text
|
||||
|
||||
|
||||
class TestAdminCreateTag:
|
||||
"""Tests for creating node tags."""
|
||||
|
||||
def test_create_tag_success(self, admin_client, auth_headers):
|
||||
"""Test creating a new tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "created" in response.headers["location"]
|
||||
|
||||
def test_create_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test creating tag when admin is disabled."""
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_create_tag_unauthenticated(self, admin_client):
|
||||
"""Test creating tag without authentication."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "new_tag",
|
||||
"value": "new_value",
|
||||
"value_type": "string",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestAdminUpdateTag:
|
||||
"""Tests for updating node tags."""
|
||||
|
||||
def test_update_tag_success(self, admin_client, auth_headers):
|
||||
"""Test updating a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "updated" in response.headers["location"]
|
||||
|
||||
def test_update_tag_not_found(
|
||||
self, admin_app, mock_http_client_admin: MockHttpClient, auth_headers
|
||||
):
|
||||
"""Test updating a non-existent tag returns error."""
|
||||
# Set up 404 response for this specific tag
|
||||
mock_http_client_admin.set_response(
|
||||
"PUT",
|
||||
"/api/v1/nodes/abc123def456abc123def456abc123de/tags/nonexistent",
|
||||
404,
|
||||
{"detail": "Tag not found"},
|
||||
)
|
||||
admin_app.state.http_client = mock_http_client_admin
|
||||
client = TestClient(admin_app, raise_server_exceptions=True)
|
||||
|
||||
response = client.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "nonexistent",
|
||||
"value": "value",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "error=" in response.headers["location"]
|
||||
assert "not+found" in response.headers["location"].lower()
|
||||
|
||||
def test_update_tag_disabled(self, admin_client_disabled, auth_headers):
|
||||
"""Test updating tag when admin is disabled."""
|
||||
response = admin_client_disabled.post(
|
||||
"/a/node-tags/update",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"value": "staging",
|
||||
"value_type": "string",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAdminMoveTag:
|
||||
"""Tests for moving node tags."""
|
||||
|
||||
def test_move_tag_success(self, admin_client, auth_headers):
|
||||
"""Test moving a tag to another node."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/move",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
"new_public_key": "xyz789xyz789xyz789xyz789xyz789xy",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
# Should redirect to destination node
|
||||
assert "xyz789xyz789xyz789xyz789xyz789xy" in response.headers["location"]
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "moved" in response.headers["location"]
|
||||
|
||||
|
||||
class TestAdminDeleteTag:
|
||||
"""Tests for deleting node tags."""
|
||||
|
||||
def test_delete_tag_success(self, admin_client, auth_headers):
|
||||
"""Test deleting a tag."""
|
||||
response = admin_client.post(
|
||||
"/a/node-tags/delete",
|
||||
data={
|
||||
"public_key": "abc123def456abc123def456abc123de",
|
||||
"key": "environment",
|
||||
},
|
||||
headers=auth_headers,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "message=" in response.headers["location"]
|
||||
assert "deleted" in response.headers["location"]
|
||||
364
tests/test_web/test_advertisements.py
Normal file
364
tests/test_web/test_advertisements.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""Tests for the advertisements page route."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestAdvertisementsPage:
|
||||
"""Tests for the advertisements page."""
|
||||
|
||||
def test_advertisements_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page returns 200 status code."""
|
||||
response = client.get("/advertisements")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page returns HTML content."""
|
||||
response = client.get("/advertisements")
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_advertisements_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that advertisements page contains the network name."""
|
||||
response = client.get("/advertisements")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_advertisements_displays_advertisement_list(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisements from API."""
|
||||
response = client.get("/advertisements")
|
||||
assert response.status_code == 200
|
||||
# Check for advertisement data from mock
|
||||
assert "Node One" in response.text
|
||||
|
||||
def test_advertisements_displays_adv_type(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page displays advertisement types."""
|
||||
response = client.get("/advertisements")
|
||||
# Should show adv type from mock data
|
||||
assert "REPEATER" in response.text
|
||||
|
||||
|
||||
class TestAdvertisementsPageFilters:
|
||||
"""Tests for advertisements page filtering."""
|
||||
|
||||
def test_advertisements_with_search(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with search parameter."""
|
||||
response = client.get("/advertisements?search=node")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with member_id filter."""
|
||||
response = client.get("/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with public_key filter."""
|
||||
response = client.get(
|
||||
"/advertisements?public_key=abc123def456abc123def456abc123de"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_pagination(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with pagination parameters."""
|
||||
response = client.get("/advertisements?page=1&limit=25")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_page_2(self, client: TestClient) -> None:
|
||||
"""Test advertisements page 2."""
|
||||
response = client.get("/advertisements?page=2")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with multiple filters."""
|
||||
response = client.get(
|
||||
"/advertisements?search=test&member_id=alice&page=1&limit=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageDropdowns:
|
||||
"""Tests for advertisements page dropdown data."""
|
||||
|
||||
def test_advertisements_loads_members_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads members for filter dropdown."""
|
||||
# Set up members response
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/members",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{"id": "m1", "member_id": "alice", "name": "Alice"},
|
||||
{"id": "m2", "member_id": "bob", "name": "Bob"},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Members should be available for dropdown
|
||||
assert "Alice" in response.text or "alice" in response.text
|
||||
|
||||
def test_advertisements_loads_nodes_for_dropdown(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page loads nodes for filter dropdown."""
|
||||
# Set up nodes response with tags
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Node Alpha",
|
||||
"tags": [{"key": "name", "value": "Custom Name"}],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Node Beta",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsNodeSorting:
|
||||
"""Tests for node sorting in advertisements dropdown."""
|
||||
|
||||
def test_nodes_sorted_by_display_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes are sorted alphabetically by display name."""
|
||||
# Set up nodes with tags - "Zebra" should come after "Alpha"
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra Node",
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"public_key": "def456",
|
||||
"name": "Alpha Node",
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Both nodes should appear
|
||||
text = response.text
|
||||
assert "Alpha Node" in text or "alpha" in text.lower()
|
||||
assert "Zebra Node" in text or "zebra" in text.lower()
|
||||
|
||||
def test_nodes_sorted_by_tag_name_when_present(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes use tag name for sorting when available."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zebra",
|
||||
"tags": [{"key": "name", "value": "Alpha Custom"}],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_nodes_fallback_to_public_key_when_no_name(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes fall back to public_key when no name."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "n1",
|
||||
"public_key": "abc123def456",
|
||||
"name": None,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPageAPIErrors:
|
||||
"""Tests for advertisements page handling API errors."""
|
||||
|
||||
def test_advertisements_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API errors gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/advertisements", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that advertisements page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
status_code=404,
|
||||
json_data={"detail": "Not found"},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders with empty list)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_members_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles members API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/members", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without member dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_nodes_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles nodes API error gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
# Should still return 200 (page renders without node dropdown)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_handles_empty_response(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that page handles empty advertisements list."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAdvertisementsPagination:
|
||||
"""Tests for advertisements pagination calculations."""
|
||||
|
||||
def test_pagination_calculates_total_pages(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that pagination correctly calculates total pages."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 150},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
# With limit=50 and total=150, should have 3 pages
|
||||
response = client.get("/advertisements?limit=50")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_pagination_with_zero_total(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test pagination with zero results shows at least 1 page."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/advertisements",
|
||||
200,
|
||||
{"items": [], "total": 0},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/advertisements")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the network overview page route."""
|
||||
"""Tests for the dashboard page route."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -7,29 +7,29 @@ from fastapi.testclient import TestClient
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestNetworkPage:
|
||||
"""Tests for the network overview page."""
|
||||
class TestDashboardPage:
|
||||
"""Tests for the dashboard page."""
|
||||
|
||||
def test_network_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that network page returns 200 status code."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page returns 200 status code."""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_network_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that network page returns HTML content."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page returns HTML content."""
|
||||
response = client.get("/dashboard")
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_network_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that network page contains the network name."""
|
||||
response = client.get("/network")
|
||||
def test_dashboard_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that dashboard page contains the network name."""
|
||||
response = client.get("/dashboard")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_network_displays_stats(
|
||||
def test_dashboard_displays_stats(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page displays statistics."""
|
||||
response = client.get("/network")
|
||||
"""Test that dashboard page displays statistics."""
|
||||
response = client.get("/dashboard")
|
||||
# Check for stats from mock response
|
||||
assert response.status_code == 200
|
||||
# The mock returns total_nodes: 10, active_nodes: 5, etc.
|
||||
@@ -37,24 +37,24 @@ class TestNetworkPage:
|
||||
assert "10" in response.text # total_nodes
|
||||
assert "5" in response.text # active_nodes
|
||||
|
||||
def test_network_displays_message_counts(
|
||||
def test_dashboard_displays_message_counts(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page displays message counts."""
|
||||
response = client.get("/network")
|
||||
"""Test that dashboard page displays message counts."""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
# Mock returns total_messages: 100, messages_today: 15
|
||||
assert "100" in response.text
|
||||
assert "15" in response.text
|
||||
|
||||
|
||||
class TestNetworkPageAPIErrors:
|
||||
"""Tests for network page handling API errors."""
|
||||
class TestDashboardPageAPIErrors:
|
||||
"""Tests for dashboard page handling API errors."""
|
||||
|
||||
def test_network_handles_api_error(
|
||||
def test_dashboard_handles_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page handles API errors gracefully."""
|
||||
"""Test that dashboard page handles API errors gracefully."""
|
||||
# Set error response for stats endpoint
|
||||
mock_http_client.set_response(
|
||||
"GET", "/api/v1/dashboard/stats", status_code=500, json_data=None
|
||||
@@ -62,15 +62,15 @@ class TestNetworkPageAPIErrors:
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/network")
|
||||
response = client.get("/dashboard")
|
||||
|
||||
# Should still return 200 (page renders with defaults)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_network_handles_api_not_found(
|
||||
def test_dashboard_handles_api_not_found(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that network page handles API 404 gracefully."""
|
||||
"""Test that dashboard page handles API 404 gracefully."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/dashboard/stats",
|
||||
@@ -80,7 +80,7 @@ class TestNetworkPageAPIErrors:
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/network")
|
||||
response = client.get("/dashboard")
|
||||
|
||||
# Should still return 200 (page renders with defaults)
|
||||
assert response.status_code == 200
|
||||
87
tests/test_web/test_health.py
Normal file
87
tests/test_web/test_health.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for the health check endpoints."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from tests.test_web.conftest import MockHttpClient
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests for the /health endpoint."""
|
||||
|
||||
def test_health_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns 200 status code."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_returns_json(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns JSON content."""
|
||||
response = client.get("/health")
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
def test_health_returns_healthy_status(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns healthy status."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_health_returns_version(self, client: TestClient) -> None:
|
||||
"""Test that health endpoint returns version."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["version"] == __version__
|
||||
|
||||
|
||||
class TestHealthReadyEndpoint:
|
||||
"""Tests for the /health/ready endpoint."""
|
||||
|
||||
def test_health_ready_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health/ready endpoint returns 200 status code."""
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_ready_returns_json(self, client: TestClient) -> None:
|
||||
"""Test that health/ready endpoint returns JSON content."""
|
||||
response = client.get("/health/ready")
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
def test_health_ready_returns_ready_status(self, client: TestClient) -> None:
|
||||
"""Test that health/ready returns ready status when API is connected."""
|
||||
response = client.get("/health/ready")
|
||||
data = response.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["api"] == "connected"
|
||||
|
||||
def test_health_ready_with_api_error(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that health/ready handles API errors gracefully."""
|
||||
mock_http_client.set_response("GET", "/health", status_code=500, json_data=None)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "not_ready"
|
||||
assert "status 500" in data["api"]
|
||||
|
||||
def test_health_ready_with_api_404(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that health/ready handles API 404 response."""
|
||||
mock_http_client.set_response(
|
||||
"GET", "/health", status_code=404, json_data={"detail": "Not found"}
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "not_ready"
|
||||
assert "status 404" in data["api"]
|
||||
@@ -173,3 +173,248 @@ class TestMapDataFiltering:
|
||||
|
||||
# Node with only lat should be excluded
|
||||
assert len(data["nodes"]) == 0
|
||||
|
||||
def test_map_data_filters_zero_coordinates(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data filters nodes with (0, 0) coordinates."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Zero Coord Node",
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node at (0, 0) should be excluded
|
||||
assert len(data["nodes"]) == 0
|
||||
|
||||
def test_map_data_uses_model_coordinates_as_fallback(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data uses model lat/lon when tags are not present."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Model Coords Node",
|
||||
"lat": 51.5074,
|
||||
"lon": -0.1278,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node should use model coordinates
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["lat"] == 51.5074
|
||||
assert data["nodes"][0]["lon"] == -0.1278
|
||||
|
||||
def test_map_data_prefers_tag_coordinates_over_model(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that tag coordinates take priority over model coordinates."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Both Coords Node",
|
||||
"lat": 51.5074,
|
||||
"lon": -0.1278,
|
||||
"tags": [
|
||||
{"key": "lat", "value": "40.7128"},
|
||||
{"key": "lon", "value": "-74.0060"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Node should use tag coordinates, not model
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["lat"] == 40.7128
|
||||
assert data["nodes"][0]["lon"] == -74.0060
|
||||
|
||||
|
||||
class TestMapDataInfrastructure:
|
||||
"""Tests for infrastructure node handling in map data."""
|
||||
|
||||
def test_map_data_includes_infra_center(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that map data includes infrastructure center when infra nodes exist."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456",
|
||||
"name": "Regular Node",
|
||||
"lat": 41.0,
|
||||
"lon": -75.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
# Should have infra_center based on infra node only
|
||||
assert data["infra_center"] is not None
|
||||
assert data["infra_center"]["lat"] == 40.0
|
||||
assert data["infra_center"]["lon"] == -74.0
|
||||
|
||||
def test_map_data_infra_center_null_when_no_infra(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that infra_center is null when no infrastructure nodes exist."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Regular Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
assert data["infra_center"] is None
|
||||
|
||||
def test_map_data_sets_is_infra_flag(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that nodes have correct is_infra flag based on role tag."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"public_key": "def456",
|
||||
"name": "Regular Node",
|
||||
"lat": 41.0,
|
||||
"lon": -75.0,
|
||||
"tags": [{"key": "role", "value": "other"}],
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
nodes_by_name = {n["name"]: n for n in data["nodes"]}
|
||||
assert nodes_by_name["Infra Node"]["is_infra"] is True
|
||||
assert nodes_by_name["Regular Node"]["is_infra"] is False
|
||||
|
||||
def test_map_data_debug_includes_infra_count(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that debug info includes infrastructure node count."""
|
||||
mock_http_client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
status_code=200,
|
||||
json_data={
|
||||
"items": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"public_key": "abc123",
|
||||
"name": "Infra Node",
|
||||
"lat": 40.0,
|
||||
"lon": -74.0,
|
||||
"tags": [{"key": "role", "value": "infra"}],
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
)
|
||||
web_app.state.http_client = mock_http_client
|
||||
|
||||
client = TestClient(web_app, raise_server_exceptions=True)
|
||||
response = client.get("/map/data")
|
||||
data = response.json()
|
||||
|
||||
assert data["debug"]["infra_nodes"] == 1
|
||||
|
||||
@@ -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
|
||||
|
||||
494
tests/test_web/test_pages.py
Normal file
494
tests/test_web/test_pages.py
Normal file
@@ -0,0 +1,494 @@
|
||||
"""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 content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create pages subdirectory (CONTENT_HOME/pages)
|
||||
pages_subdir = Path(tmpdir) / "pages"
|
||||
pages_subdir.mkdir()
|
||||
(pages_subdir / "about.md").write_text(
|
||||
"""---
|
||||
title: About Us
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# About Our Network
|
||||
|
||||
Welcome to the network.
|
||||
"""
|
||||
)
|
||||
(pages_subdir / "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 CONTENT_HOME environment variable
|
||||
os.environ["CONTENT_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["CONTENT_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 content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create pages subdirectory (CONTENT_HOME/pages)
|
||||
pages_subdir = Path(tmpdir) / "pages"
|
||||
pages_subdir.mkdir()
|
||||
(pages_subdir / "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["CONTENT_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["CONTENT_HOME"]
|
||||
|
||||
def test_pages_included_in_sitemap(
|
||||
self, client_with_pages_for_sitemap: TestClient
|
||||
) -> None:
|
||||
"""Test that custom pages are included in sitemap.xml."""
|
||||
response = client_with_pages_for_sitemap.get("/sitemap.xml")
|
||||
assert response.status_code == 200
|
||||
assert "/pages/about" in response.text
|
||||
assert "<changefreq>weekly</changefreq>" in response.text
|
||||
Reference in New Issue
Block a user