forked from iarv/meshcore-hub
Compare commits
67 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 |
@@ -187,6 +187,11 @@ API_ADMIN_KEY=
|
||||
# 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
|
||||
# -------------------
|
||||
@@ -216,3 +221,4 @@ NETWORK_WELCOME_TEXT=
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
NETWORK_CONTACT_GITHUB=
|
||||
NETWORK_CONTACT_YOUTUBE=
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -18,20 +16,8 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black flake8 mypy
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Check formatting with black
|
||||
run: black --check src/ tests/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: flake8 src/ tests/
|
||||
|
||||
- name: Type check with mypy
|
||||
run: mypy src/
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
|
||||
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:*)'
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -455,10 +455,12 @@ 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
|
||||
- `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.
|
||||
@@ -472,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}/
|
||||
|
||||
251
README.md
251
README.md
@@ -80,9 +80,13 @@ The quickest way to get started is running the entire stack on a single machine
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
# Create a directory, download the Docker Compose file and
|
||||
# example environment configuration file
|
||||
|
||||
mkdir meshcore-hub
|
||||
cd meshcore-hub
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/.env.example
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
@@ -156,9 +160,9 @@ This architecture allows:
|
||||
- Community members to contribute coverage with minimal setup
|
||||
- The central server to be hosted anywhere with internet access
|
||||
|
||||
## Quick Start
|
||||
## Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Docker Compose Profiles
|
||||
|
||||
Docker Compose uses **profiles** to select which services to run:
|
||||
|
||||
@@ -175,14 +179,6 @@ Docker Compose uses **profiles** to select which services to run:
|
||||
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ipnet-mesh/meshcore-hub.git
|
||||
cd meshcore-hub
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings (API keys, serial port, network info)
|
||||
|
||||
# Create database schema
|
||||
docker compose --profile migrate run --rm db-migrate
|
||||
|
||||
@@ -205,7 +201,7 @@ docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
#### Serial Device Access
|
||||
### Serial Device Access
|
||||
|
||||
For production with real MeshCore devices, ensure the serial port is accessible:
|
||||
|
||||
@@ -221,13 +217,25 @@ SERIAL_PORT=/dev/ttyUSB0
|
||||
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
|
||||
```
|
||||
|
||||
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
|
||||
|
||||
```bash
|
||||
# List available devices by ID
|
||||
ls -la /dev/serial/by-id/
|
||||
|
||||
# Example output:
|
||||
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
|
||||
|
||||
# Configure using the stable ID
|
||||
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the package
|
||||
pip install -e ".[dev]"
|
||||
@@ -242,57 +250,6 @@ meshcore-hub api
|
||||
meshcore-hub web
|
||||
```
|
||||
|
||||
## Updating an Existing Installation
|
||||
|
||||
To update MeshCore Hub to the latest version:
|
||||
|
||||
```bash
|
||||
# Navigate to your installation directory
|
||||
cd meshcore-hub
|
||||
|
||||
# Pull the latest code
|
||||
git pull
|
||||
|
||||
# Pull latest Docker images
|
||||
docker compose --profile all pull
|
||||
|
||||
# Recreate and restart services
|
||||
# For receiver/sender only installs:
|
||||
docker compose --profile receiver up -d --force-recreate
|
||||
|
||||
# For core services with MQTT:
|
||||
docker compose --profile mqtt --profile core up -d --force-recreate
|
||||
|
||||
# For core services without local MQTT:
|
||||
docker compose --profile core up -d --force-recreate
|
||||
|
||||
# For complete stack (all services):
|
||||
docker compose --profile mqtt --profile core --profile receiver up -d --force-recreate
|
||||
|
||||
# View logs to verify update
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
**Note:** Database migrations run automatically on collector startup, so no manual migration step is needed when using Docker.
|
||||
|
||||
For manual installations:
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Update dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run database migrations
|
||||
meshcore-hub db upgrade
|
||||
|
||||
# Restart your services
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All components are configured via environment variables. Create a `.env` file or export variables:
|
||||
@@ -319,11 +276,7 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
| `SERIAL_BAUD` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
|
||||
|
||||
### Collector Settings
|
||||
|
||||
The database is stored in `{DATA_HOME}/collector/meshcore.db` by default.
|
||||
|
||||
#### Webhook Configuration
|
||||
### Webhooks
|
||||
|
||||
The collector can forward certain events to external HTTP endpoints:
|
||||
|
||||
@@ -348,7 +301,7 @@ Webhook payload format:
|
||||
}
|
||||
```
|
||||
|
||||
#### Data Retention
|
||||
### Data Retention
|
||||
|
||||
The collector automatically cleans up old event data and inactive nodes:
|
||||
|
||||
@@ -377,6 +330,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `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) |
|
||||
@@ -385,51 +339,74 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `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
|
||||
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 database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
|
||||
### Running the Seed Process
|
||||
#### Running the Seed Process
|
||||
|
||||
Seeding is a separate process and must be run explicitly:
|
||||
|
||||
```bash
|
||||
# Native CLI
|
||||
meshcore-hub collector seed
|
||||
|
||||
# With Docker Compose
|
||||
docker compose --profile seed up
|
||||
```
|
||||
|
||||
@@ -437,7 +414,7 @@ This imports data from the following files (if they exist):
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
### Directory Structure
|
||||
#### Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
@@ -451,11 +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:
|
||||
|
||||
@@ -484,24 +461,11 @@ Tag values can be:
|
||||
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
### Import Tags Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/node_tags.yaml)
|
||||
meshcore-hub collector import-tags
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-tags /path/to/node_tags.yaml
|
||||
|
||||
# Skip tags for nodes that don't exist
|
||||
meshcore-hub collector import-tags --no-create-nodes
|
||||
```
|
||||
|
||||
## Network Members
|
||||
### Network Members
|
||||
|
||||
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
|
||||
|
||||
### Members YAML Format
|
||||
#### Members YAML Format
|
||||
|
||||
```yaml
|
||||
- member_id: walshie86
|
||||
@@ -526,44 +490,6 @@ Network members represent the people operating nodes in your network. Members ca
|
||||
| `contact` | No | Contact information |
|
||||
| `public_key` | No | Associated node public key (64-char hex) |
|
||||
|
||||
### Import Members Manually
|
||||
|
||||
```bash
|
||||
# Import from default location ({SEED_HOME}/members.yaml)
|
||||
meshcore-hub collector import-members
|
||||
|
||||
# Import from specific file
|
||||
meshcore-hub collector import-members /path/to/members.yaml
|
||||
```
|
||||
|
||||
### Managing Tags via API
|
||||
|
||||
Tags can also be managed via the REST API:
|
||||
|
||||
```bash
|
||||
# List tags for a node
|
||||
curl http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Create a tag (requires admin key)
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "location", "value": "Building A"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags
|
||||
|
||||
# Update a tag
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": "Building B"}' \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
|
||||
# Delete a tag
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer <API_ADMIN_KEY>" \
|
||||
http://localhost:8000/api/v1/nodes/{public_key}/tags/location
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
When running, the API provides interactive documentation at:
|
||||
@@ -675,10 +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
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- "${MQTT_EXTERNAL_PORT:-1883}:1883"
|
||||
- "${MQTT_WS_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
# - ./etc/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_log:/mosquitto/log
|
||||
healthcheck:
|
||||
@@ -142,7 +142,7 @@ services:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
@@ -154,8 +154,6 @@ services:
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Webhook configuration
|
||||
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
|
||||
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
|
||||
@@ -203,8 +201,7 @@ services:
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -214,8 +211,6 @@ services:
|
||||
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
- API_HOST=0.0.0.0
|
||||
- API_PORT=8000
|
||||
- API_READ_KEY=${API_READ_KEY:-}
|
||||
@@ -246,6 +241,8 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
@@ -262,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')"]
|
||||
@@ -286,12 +286,9 @@ services:
|
||||
- migrate
|
||||
restart: "no"
|
||||
volumes:
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
- hub_data:/data
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
command: ["db", "upgrade"]
|
||||
|
||||
# ==========================================================================
|
||||
@@ -309,20 +306,13 @@ services:
|
||||
profiles:
|
||||
- seed
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
# Mount data directory for database (read-write)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
# Mount seed directory for seed files (read-only)
|
||||
- hub_data:/data
|
||||
- ${SEED_HOME:-./seed}:/seed:ro
|
||||
environment:
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
# Explicitly unset to use DATA_HOME-based default path
|
||||
- DATABASE_URL=
|
||||
# Imports both node_tags.yaml and members.yaml if they exist
|
||||
command: ["collector", "seed"]
|
||||
|
||||
@@ -330,6 +320,8 @@ services:
|
||||
# Volumes
|
||||
# ==========================================================================
|
||||
volumes:
|
||||
hub_data:
|
||||
name: meshcore_hub_data
|
||||
mosquitto_data:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
|
||||
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!
|
||||
@@ -39,6 +39,8 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"meshcore>=2.2.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -111,6 +113,8 @@ module = [
|
||||
"uvicorn.*",
|
||||
"alembic.*",
|
||||
"meshcore.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -23,6 +23,7 @@ async def list_nodes(
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
role: Optional[str] = Query(None, description="Filter by role tag value"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
@@ -59,6 +60,16 @@ async def list_nodes(
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# Filter nodes that have a role tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "role", NodeTag.value == role
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
@@ -77,14 +88,43 @@ async def list_nodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
@router.get("/prefix/{prefix}", response_model=NodeRead)
|
||||
async def get_node_by_prefix(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
prefix: str = Path(description="Public key prefix to search for"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by public key."""
|
||||
query = select(Node).where(Node.public_key == public_key)
|
||||
"""Get a single node by public key prefix.
|
||||
|
||||
Returns the first node (alphabetically by public_key) that matches the prefix.
|
||||
"""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key.startswith(prefix))
|
||||
.order_by(Node.public_key)
|
||||
.limit(1)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return NodeRead.model_validate(node)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str = Path(description="Full 64-character public key"),
|
||||
) -> NodeRead:
|
||||
"""Get a single node by exact public key match."""
|
||||
query = (
|
||||
select(Node)
|
||||
.options(selectinload(Node.tags))
|
||||
.where(Node.public_key == public_key)
|
||||
)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
|
||||
@@ -253,6 +253,9 @@ 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,
|
||||
@@ -291,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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
@@ -58,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.
|
||||
@@ -76,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:
|
||||
@@ -95,6 +172,10 @@ 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
|
||||
@@ -116,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
|
||||
|
||||
@@ -150,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
|
||||
|
||||
|
||||
@@ -166,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,
|
||||
@@ -174,7 +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,25 +3,27 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.web.routes.home import router as home_router
|
||||
from meshcore_hub.web.routes.network import router as network_router
|
||||
from meshcore_hub.web.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.web.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
from meshcore_hub.web.routes.pages import router as pages_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(network_router)
|
||||
web_router.include_router(dashboard_router)
|
||||
web_router.include_router(nodes_router)
|
||||
web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
web_router.include_router(pages_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
|
||||
@@ -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)
|
||||
@@ -65,11 +65,11 @@ 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
|
||||
@@ -78,12 +78,12 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
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":
|
||||
@@ -93,35 +93,40 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
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 by member_id tag
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
# 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",
|
||||
"member_id": node_member_id,
|
||||
"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}")
|
||||
@@ -130,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:
|
||||
@@ -145,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,
|
||||
@@ -153,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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
@@ -81,25 +81,45 @@ async def nodes_list(
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
|
||||
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
"""Render the node detail page."""
|
||||
@router.get("/n/{prefix}")
|
||||
async def node_short_link(prefix: str) -> RedirectResponse:
|
||||
"""Redirect short link to nodes page."""
|
||||
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_model=None)
|
||||
async def node_detail(
|
||||
request: Request, public_key: str
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
"""Render the node detail page.
|
||||
|
||||
If the key is not a full 64-character public key, uses the prefix API
|
||||
to resolve it and redirects to the canonical URL.
|
||||
"""
|
||||
# If not a full public key, resolve via prefix API and redirect
|
||||
if len(public_key) != 64:
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/prefix/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
node = None
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
try:
|
||||
# Fetch node details
|
||||
response = await request.app.state.http_client.get(
|
||||
f"/api/v1/nodes/{public_key}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node = response.json()
|
||||
# Fetch node details (exact match)
|
||||
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node = response.json()
|
||||
|
||||
try:
|
||||
# Fetch recent advertisements for this node
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params={"public_key": public_key, "limit": 10}
|
||||
|
||||
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 |
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>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -70,9 +70,35 @@ function populateRelativeTimeElements() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_lock %}
|
||||
|
||||
{% block title %}{{ network_name }} - Access Denied{% endblock %}
|
||||
{% 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{{ 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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_user, icon_email, icon_users, icon_tag %}
|
||||
|
||||
{% block title %}{{ network_name }} - Admin{% endblock %}
|
||||
{% block title %}Admin - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -20,19 +21,13 @@
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
|
||||
{% if auth_username or auth_user %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ icon_user("h-4 w-4") }}
|
||||
{{ auth_username or auth_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if auth_email %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ icon_email("h-4 w-4") }}
|
||||
{{ auth_email }}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -43,11 +38,7 @@
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
{{ icon_users("h-6 w-6") }}
|
||||
Members
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
@@ -56,11 +47,7 @@
|
||||
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{{ icon_tag("h-6 w-6") }}
|
||||
Node Tags
|
||||
</h2>
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_success, icon_error, icon_alert %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members Admin{% endblock %}
|
||||
{% block title %}Admin: Members - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -20,18 +21,14 @@
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -232,9 +229,7 @@
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_success, icon_error, icon_alert, icon_info, icon_tag %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
|
||||
{% block title %}Admin: Node Tags - {{ network_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -20,18 +21,14 @@
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -107,7 +104,7 @@
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">{{ tag.value_type }}</span>
|
||||
</td>
|
||||
<td class="text-sm opacity-70">{{ tag.updated_at[:10] if tag.updated_at else '-' }}</td>
|
||||
<td 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">
|
||||
@@ -253,9 +250,7 @@
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ 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>
|
||||
|
||||
@@ -281,9 +276,7 @@
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
@@ -324,9 +317,7 @@
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ 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>
|
||||
|
||||
@@ -351,9 +342,7 @@
|
||||
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
|
||||
<span>This action cannot be undone. All tags will be permanently deleted.</span>
|
||||
</div>
|
||||
|
||||
@@ -370,17 +359,13 @@
|
||||
|
||||
{% elif selected_public_key and not selected_node %}
|
||||
<div class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{{ 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>
|
||||
|
||||
@@ -1,19 +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 %}
|
||||
@@ -21,7 +23,7 @@
|
||||
<!-- 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>
|
||||
@@ -87,7 +89,7 @@
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
|
||||
{{ ad.received_at|localtime_short }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
@@ -134,7 +136,7 @@
|
||||
</a>
|
||||
</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 %}
|
||||
|
||||
@@ -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,6 +113,10 @@
|
||||
{% 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">{% 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>
|
||||
|
||||
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">
|
||||
@@ -156,15 +158,11 @@
|
||||
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
{{ 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{{ icon_github("h-4 w-4 mr-1") }}
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
@@ -175,9 +173,7 @@
|
||||
<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>
|
||||
{{ icon_chart("h-6 w-6") }}
|
||||
Network Activity
|
||||
</h2>
|
||||
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
|
||||
@@ -191,99 +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 advertData = {{ advert_activity_json | safe }};
|
||||
const messageData = {{ message_activity_json | safe }};
|
||||
const ctx = document.getElementById('activityChart');
|
||||
|
||||
if (ctx && advertData.data && advertData.data.length > 0) {
|
||||
// Format dates for display (show only day/month)
|
||||
const labels = advertData.data.map(d => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
const advertCounts = advertData.data.map(d => d.count);
|
||||
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Advertisements',
|
||||
data: advertCounts,
|
||||
borderColor: 'oklch(0.7 0.17 330)',
|
||||
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}, {
|
||||
label: 'Messages',
|
||||
data: messageCounts,
|
||||
borderColor: 'oklch(0.7 0.15 200)',
|
||||
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'oklch(0.7 0 0)',
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
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>
|
||||
@@ -52,6 +45,12 @@
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,263 +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 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.</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;
|
||||
}
|
||||
|
||||
// formatRelativeTime is provided by /static/js/utils.js
|
||||
|
||||
// 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 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; 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) {
|
||||
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${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 memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// 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(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 member filter dropdown
|
||||
function populateMemberFilter() {
|
||||
const select = document.getElementById('filter-member');
|
||||
|
||||
// Sort members by name
|
||||
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options for all members
|
||||
sortedMembers.forEach(member => {
|
||||
if (member.member_id) {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.member_id;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-member').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').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 member filter
|
||||
populateMemberFilter();
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -59,7 +60,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen|localtime }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -71,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,19 +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 %}
|
||||
@@ -21,7 +23,7 @@
|
||||
<!-- 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>
|
||||
@@ -63,7 +65,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ msg.received_at[:16].replace('T', ' ') if msg.received_at else '-' }}
|
||||
{{ msg.received_at|localtime_short }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +108,7 @@
|
||||
{% 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' %}
|
||||
|
||||
@@ -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,107 +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 or (admin_enabled and is_authenticated) %}
|
||||
<!-- 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>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
@@ -164,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>
|
||||
@@ -203,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>
|
||||
@@ -256,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>
|
||||
@@ -267,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,19 +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 %}
|
||||
@@ -21,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>
|
||||
@@ -86,7 +88,7 @@
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:10] }}
|
||||
{{ node.last_seen|localdate }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
@@ -144,7 +146,7 @@
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
{{ node.last_seen|localtime }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
@@ -146,6 +146,54 @@ class TestGetNode:
|
||||
response = client_no_auth.get("/api/v1/nodes/nonexistent123")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_node_by_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by public key prefix."""
|
||||
prefix = sample_node.public_key[:8] # First 8 chars
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_by_single_char_prefix(self, client_no_auth, sample_node):
|
||||
"""Test getting a node by single character prefix."""
|
||||
prefix = sample_node.public_key[0]
|
||||
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
|
||||
def test_get_node_prefix_returns_first_alphabetically(
|
||||
self, client_no_auth, api_db_session
|
||||
):
|
||||
"""Test that prefix match returns first node alphabetically."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
# Create two nodes with same prefix but different suffixes
|
||||
# abc... should come before abd...
|
||||
node_a = Node(
|
||||
public_key="abc0000000000000000000000000000000000000000000000000000000000000",
|
||||
name="Node A",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
node_b = Node(
|
||||
public_key="abc1111111111111111111111111111111111111111111111111111111111111",
|
||||
name="Node B",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node_a)
|
||||
api_db_session.add(node_b)
|
||||
api_db_session.commit()
|
||||
|
||||
# Request with prefix should return first alphabetically
|
||||
response = client_no_auth.get("/api/v1/nodes/prefix/abc")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == node_a.public_key
|
||||
|
||||
|
||||
class TestNodeTags:
|
||||
"""Tests for node tag endpoints."""
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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