mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470c374f11 | ||
|
|
71859b2168 | ||
|
|
3d7ed53df3 | ||
|
|
ceaef9178a | ||
|
|
5ccb077188 | ||
|
|
8f660d6b94 | ||
|
|
6e40be6487 | ||
|
|
d79e29bc0a | ||
|
|
2758cf4dd5 | ||
|
|
f37e993ede | ||
|
|
b18b3c9aa4 | ||
|
|
9d99262401 | ||
|
|
adfe5bc503 | ||
|
|
deaab9b9de | ||
|
|
95636ef580 | ||
|
|
5831592f88 | ||
|
|
bc7bff8b82 | ||
|
|
9445d2150c | ||
|
|
3e9f478a65 |
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:*)'
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -455,7 +455,7 @@ See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list.
|
||||
Key variables:
|
||||
- `DATA_HOME` - Base directory for runtime data (default: `./data`)
|
||||
- `SEED_HOME` - Directory containing seed data files (default: `./seed`)
|
||||
- `PAGES_HOME` - Directory containing custom markdown pages (default: `./pages`)
|
||||
- `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
|
||||
@@ -473,12 +473,16 @@ ${SEED_HOME}/
|
||||
└── members.yaml # Network members list
|
||||
```
|
||||
|
||||
**Custom Pages (`PAGES_HOME`)** - Contains custom markdown pages for the web dashboard:
|
||||
**Custom Content (`CONTENT_HOME`)** - Contains custom pages and media for the web dashboard:
|
||||
```
|
||||
${PAGES_HOME}/
|
||||
├── about.md # Example: About page (/pages/about)
|
||||
├── faq.md # Example: FAQ page (/pages/faq)
|
||||
└── getting-started.md # Example: Getting Started (/pages/getting-started)
|
||||
${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:
|
||||
|
||||
39
README.md
39
README.md
@@ -338,19 +338,28 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
|
||||
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
|
||||
| `PAGES_HOME` | `./pages` | Directory containing custom markdown pages |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
### Custom Pages
|
||||
### Custom Content
|
||||
|
||||
The web dashboard supports custom markdown pages for adding static content like "About Us", "Getting Started", or "FAQ" pages. Pages are stored as markdown files with YAML frontmatter.
|
||||
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
|
||||
# Create pages directory
|
||||
mkdir -p pages
|
||||
# Create content directory structure
|
||||
mkdir -p content/pages content/media
|
||||
|
||||
# Create a custom page
|
||||
cat > pages/about.md << 'EOF'
|
||||
cat > content/pages/about.md << 'EOF'
|
||||
---
|
||||
title: About Us
|
||||
slug: about
|
||||
@@ -378,14 +387,14 @@ EOF
|
||||
|
||||
The markdown content is rendered as-is, so include your own `# Heading` if desired.
|
||||
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the pages directory:
|
||||
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the content directory:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (already configured)
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- PAGES_HOME=/pages
|
||||
- CONTENT_HOME=/content
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
@@ -594,10 +603,16 @@ meshcore-hub/
|
||||
│ ├── seed/ # Example seed data files
|
||||
│ │ ├── node_tags.yaml # Example node tags
|
||||
│ │ └── members.yaml # Example network members
|
||||
│ └── pages/ # Example custom pages
|
||||
│ └── about.md # Example about page
|
||||
│ └── 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/)
|
||||
├── pages/ # Custom pages directory (PAGES_HOME, optional)
|
||||
├── 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
|
||||
|
||||
@@ -242,7 +242,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ${PAGES_HOME:-./pages}:/pages:ro
|
||||
- ${CONTENT_HOME:-./content}:/content:ro
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
@@ -260,7 +260,7 @@ services:
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- PAGES_HOME=/pages
|
||||
- CONTENT_HOME=/content
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
|
||||
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!
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
title: About
|
||||
slug: about
|
||||
menu_order: 10
|
||||
---
|
||||
|
||||
# About Our Network
|
||||
|
||||
Welcome to our MeshCore mesh network! This page demonstrates the custom pages feature.
|
||||
|
||||
## What is MeshCore?
|
||||
|
||||
MeshCore is an open-source off-grid LoRa mesh networking platform. It enables peer-to-peer communication without relying on traditional internet or cellular infrastructure.
|
||||
|
||||
## Our Mission
|
||||
|
||||
Our community-operated network aims to:
|
||||
|
||||
- Provide resilient communication during emergencies
|
||||
- Enable outdoor enthusiasts to stay connected in remote areas
|
||||
- Build a community of mesh networking enthusiasts
|
||||
|
||||
## Getting Started
|
||||
|
||||
To join our network, you'll need:
|
||||
|
||||
1. A compatible LoRa device (T-Beam, Heltec, RAK, etc.)
|
||||
2. MeshCore firmware installed
|
||||
3. The correct radio configuration for our region
|
||||
@@ -295,18 +295,32 @@ class WebSettings(CommonSettings):
|
||||
default=None, description="Welcome text for homepage"
|
||||
)
|
||||
|
||||
# Custom pages directory
|
||||
pages_home: Optional[str] = Field(
|
||||
# Content directory (contains pages/ and media/ subdirectories)
|
||||
content_home: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Directory containing custom markdown pages (default: ./pages)",
|
||||
description="Directory containing custom content (pages/, media/) (default: ./content)",
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_pages_home(self) -> str:
|
||||
"""Get the effective pages home directory."""
|
||||
def effective_content_home(self) -> str:
|
||||
"""Get the effective content home directory."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.pages_home or "./pages"))
|
||||
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:
|
||||
|
||||
@@ -132,10 +132,23 @@ def create_app(
|
||||
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
|
||||
|
||||
@@ -180,7 +193,7 @@ def create_app(
|
||||
# Static pages
|
||||
static_pages = [
|
||||
("", "daily", "1.0"),
|
||||
("/network", "hourly", "0.9"),
|
||||
("/dashboard", "hourly", "0.9"),
|
||||
("/nodes", "hourly", "0.9"),
|
||||
("/advertisements", "hourly", "0.8"),
|
||||
("/messages", "hourly", "0.8"),
|
||||
@@ -292,5 +305,6 @@ def get_network_context(request: Request) -> dict:
|
||||
"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__,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
@@ -17,7 +17,7 @@ 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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
21
src/meshcore_hub/web/static/img/logo.svg
Normal file
21
src/meshcore_hub/web/static/img/logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 70 70"
|
||||
width="70"
|
||||
height="70"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- WiFi arcs radiating from bottom-left corner -->
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round">
|
||||
<!-- Inner arc: from right to top -->
|
||||
<path d="M 20,65 A 15,15 0 0 0 5,50" />
|
||||
<!-- Middle arc -->
|
||||
<path d="M 35,65 A 30,30 0 0 0 5,35" />
|
||||
<!-- Outer arc -->
|
||||
<path d="M 50,65 A 45,45 0 0 0 5,20" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
@@ -1,3 +1,4 @@
|
||||
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -24,7 +25,10 @@
|
||||
<meta name="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/meshcore.svg">
|
||||
<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" />
|
||||
@@ -83,6 +87,70 @@
|
||||
.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 - Cross-document page transitions */
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* Respect 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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
@@ -98,36 +166,34 @@
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
|
||||
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">Advertisements</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
|
||||
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
|
||||
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ page.title }}</a></li>
|
||||
<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") }} Adverts</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ page.title }}</a></li>
|
||||
<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>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<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">
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Activity Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<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">
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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">
|
||||
@@ -1,93 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero py-8 bg-base-100 rounded-box">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-4 justify-center flex-wrap">
|
||||
<a href="/network" class="btn btn-neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Nodes
|
||||
</a>
|
||||
<a href="/advertisements" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
Advertisements
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
Messages
|
||||
</a>
|
||||
<!-- Hero Section with Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
|
||||
<!-- Hero Content (2 columns) -->
|
||||
<div class="lg:col-span-2 flex flex-col items-center text-center">
|
||||
<!-- 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">
|
||||
|
||||
47
src/meshcore_hub/web/templates/macros/icons.html
Normal file
47
src/meshcore_hub/web/templates/macros/icons.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% macro icon_dashboard(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_map(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_nodes(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_advertisements(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_messages(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_home(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_members(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icon_page(class="h-5 w-5") %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
@@ -16,6 +16,21 @@
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,6 +47,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 +76,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,30 +97,25 @@
|
||||
<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>
|
||||
<img src="{{ logo_url }}" alt="Infrastructure" class="h-5 w-5">
|
||||
<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>Node</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>
|
||||
// Logo URL for infrastructure nodes
|
||||
const logoUrl = "{{ logo_url }}";
|
||||
|
||||
// Initialize map with world view (will be centered on nodes once loaded)
|
||||
const map = L.map('map').setView([0, 0], 2);
|
||||
|
||||
@@ -104,6 +129,47 @@
|
||||
let allMembers = [];
|
||||
let markers = [];
|
||||
let mapCenter = { lat: 0, lon: 0 };
|
||||
let infraCenter = null;
|
||||
|
||||
// Maximum radius (km) from anchor point for bounds calculation
|
||||
const MAX_BOUNDS_RADIUS_KM = 20;
|
||||
|
||||
// Padding for fitBounds - more padding on mobile for tighter zoom
|
||||
const isMobilePortrait = window.innerWidth < 480;
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const 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) {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const 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);
|
||||
const 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(n =>
|
||||
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((sum, n) => sum + n.lat, 0) / nodes.length,
|
||||
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize adv_type to lowercase for consistent comparison
|
||||
function normalizeType(type) {
|
||||
@@ -112,15 +178,6 @@
|
||||
|
||||
// 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);
|
||||
@@ -132,18 +189,26 @@
|
||||
|
||||
// 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})` : '';
|
||||
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
let iconHtml;
|
||||
if (node.is_infra) {
|
||||
iconHtml = `<img src="${logoUrl}" alt="Infra" style="width: 24px; height: 24px; filter: drop-shadow(0 0 2px #1a237e) drop-shadow(0 0 4px #1a237e) drop-shadow(0 1px 2px rgba(0,0,0,0.7));">`;
|
||||
} 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 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>
|
||||
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: [82, 28],
|
||||
iconAnchor: [14, 14]
|
||||
iconSize: [120, 50],
|
||||
iconAnchor: [60, 12]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,12 +227,16 @@
|
||||
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);
|
||||
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
const iconHtml = node.is_infra
|
||||
? `<img src="${logoUrl}" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">`
|
||||
: `<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>`;
|
||||
|
||||
return `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${emoji} ${node.name}</h3>
|
||||
<h3 class="font-bold text-lg mb-2">${iconHtml} ${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
|
||||
${roleHtml}
|
||||
@@ -176,7 +245,7 @@
|
||||
<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>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-outline btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -189,13 +258,18 @@
|
||||
|
||||
// Core filter logic - returns filtered nodes and updates markers
|
||||
function applyFiltersCore() {
|
||||
const categoryFilter = document.getElementById('filter-category').value;
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Category filter (infrastructure only)
|
||||
if (categoryFilter === 'infra' && !node.is_infra) return false;
|
||||
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
const 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) {
|
||||
@@ -234,11 +308,23 @@
|
||||
// Apply filters and recenter map on filtered nodes
|
||||
function applyFilters() {
|
||||
const filteredNodes = applyFiltersCore();
|
||||
const categoryFilter = document.getElementById('filter-category').value;
|
||||
|
||||
// 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] });
|
||||
let nodesToFit = filteredNodes;
|
||||
|
||||
// Apply radius filter when showing all nodes (not infra-only)
|
||||
if (categoryFilter !== 'infra') {
|
||||
const anchor = getAnchorPoint(filteredNodes);
|
||||
const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
if (nearbyNodes.length > 0) {
|
||||
nodesToFit = nearbyNodes;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [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);
|
||||
}
|
||||
@@ -271,14 +357,30 @@
|
||||
|
||||
// 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() {
|
||||
const showLabels = document.getElementById('show-labels').checked;
|
||||
const 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
|
||||
@@ -288,6 +390,7 @@
|
||||
allNodes = data.nodes;
|
||||
allMembers = data.members || [];
|
||||
mapCenter = data.center;
|
||||
infraCenter = data.infra_center;
|
||||
|
||||
// Log debug info
|
||||
const debug = data.debug || {};
|
||||
@@ -312,10 +415,18 @@
|
||||
// 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] });
|
||||
// Initial display - center map on infrastructure nodes if available, else nodes within radius
|
||||
const infraNodes = allNodes.filter(n => n.is_infra);
|
||||
if (infraNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(infraNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (allNodes.length > 0) {
|
||||
// Use radius filter to exclude outliers
|
||||
const anchor = getAnchorPoint(allNodes);
|
||||
const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
}
|
||||
|
||||
// Apply filters (won't re-center since we just did above)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
#node-map {
|
||||
height: 300px;
|
||||
@@ -56,36 +57,28 @@
|
||||
<!-- Node Info 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>
|
||||
<!-- Title Row with Activity -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<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 %}
|
||||
{% 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>
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
<div class="text-sm text-right">
|
||||
<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>
|
||||
|
||||
<!-- Tags and Map Grid -->
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
@@ -95,42 +88,17 @@
|
||||
{% 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) %}
|
||||
<!-- Public Key + QR Code and Map Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||
<!-- Public Key and QR Code -->
|
||||
<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>
|
||||
<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 class="mt-4">
|
||||
<div id="qr-code" class="inline-block bg-white p-3 rounded"></div>
|
||||
<p class="text-xs opacity-50 mt-2">Scan to add as contact</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
@@ -143,6 +111,42 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
{% if node.tags or (admin_enabled and is_authenticated) %}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -267,6 +271,51 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_qr = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_qr.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<script>
|
||||
// Generate QR code for adding contact
|
||||
(function() {
|
||||
const nodeName = {{ (ns_qr.tag_name or node.name or 'Node') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
const advType = {{ (node.adv_type or '') | tojson }};
|
||||
|
||||
// Map adv_type to numeric type for meshcore:// protocol
|
||||
const typeMap = {
|
||||
'chat': 1,
|
||||
'repeater': 2,
|
||||
'room': 3,
|
||||
'sensor': 4
|
||||
};
|
||||
const typeNum = typeMap[advType.toLowerCase()] || 1;
|
||||
|
||||
// Build meshcore:// URL
|
||||
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(nodeName)}&public_key=${publicKey}&type=${typeNum}`;
|
||||
|
||||
// Generate QR code
|
||||
const qrContainer = document.getElementById('qr-code');
|
||||
if (qrContainer && typeof QRCode !== 'undefined') {
|
||||
try {
|
||||
new QRCode(qrContainer, {
|
||||
text: meshcoreUrl,
|
||||
width: 256,
|
||||
height: 256,
|
||||
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>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -346,9 +346,12 @@ class TestPagesRoute:
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
"""Create a temporary content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
# 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
|
||||
@@ -360,7 +363,7 @@ menu_order: 10
|
||||
Welcome to the network.
|
||||
"""
|
||||
)
|
||||
(Path(tmpdir) / "faq.md").write_text(
|
||||
(pages_subdir / "faq.md").write_text(
|
||||
"""---
|
||||
title: FAQ
|
||||
slug: faq
|
||||
@@ -381,8 +384,8 @@ Here are some answers.
|
||||
"""Create a web app with custom pages configured."""
|
||||
import os
|
||||
|
||||
# Temporarily set PAGES_HOME environment variable
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
# Temporarily set CONTENT_HOME environment variable
|
||||
os.environ["CONTENT_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
@@ -396,7 +399,7 @@ Here are some answers.
|
||||
yield app
|
||||
|
||||
# Cleanup
|
||||
del os.environ["PAGES_HOME"]
|
||||
del os.environ["CONTENT_HOME"]
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_pages(
|
||||
@@ -442,9 +445,12 @@ class TestPagesInSitemap:
|
||||
|
||||
@pytest.fixture
|
||||
def pages_dir(self) -> Generator[str, None, None]:
|
||||
"""Create a temporary directory with test pages."""
|
||||
"""Create a temporary content directory with test pages."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "about.md").write_text(
|
||||
# Create pages subdirectory (CONTENT_HOME/pages)
|
||||
pages_subdir = Path(tmpdir) / "pages"
|
||||
pages_subdir.mkdir()
|
||||
(pages_subdir / "about.md").write_text(
|
||||
"""---
|
||||
title: About
|
||||
slug: about
|
||||
@@ -462,7 +468,7 @@ About page.
|
||||
"""Create a test client with custom pages for sitemap testing."""
|
||||
import os
|
||||
|
||||
os.environ["PAGES_HOME"] = pages_dir
|
||||
os.environ["CONTENT_HOME"] = pages_dir
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
@@ -476,7 +482,7 @@ About page.
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
del os.environ["PAGES_HOME"]
|
||||
del os.environ["CONTENT_HOME"]
|
||||
|
||||
def test_pages_included_in_sitemap(
|
||||
self, client_with_pages_for_sitemap: TestClient
|
||||
|
||||
Reference in New Issue
Block a user