mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
||||
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,7 @@
|
||||
<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 }}">
|
||||
|
||||
<!-- Tailwind CSS with DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
@@ -98,36 +99,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>
|
||||
|
||||
@@ -1,89 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero py-8 bg-base-100 rounded-box">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
{% if network_welcome_text %}
|
||||
<p class="py-4">{{ network_welcome_text }}</p>
|
||||
{% else %}
|
||||
<p class="py-4">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-4 justify-center flex-wrap">
|
||||
<a href="/network" class="btn btn-neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Nodes
|
||||
</a>
|
||||
<a href="/advertisements" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
Advertisements
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
Messages
|
||||
</a>
|
||||
<!-- Hero Section with Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
|
||||
<!-- Hero Content (2 columns) -->
|
||||
<div class="lg:col-span-2 flex flex-col items-center text-center">
|
||||
<!-- 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>
|
||||
</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>
|
||||
{% 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>
|
||||
</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>
|
||||
<!-- Stats Column (stacked vertically) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 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>
|
||||
<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>
|
||||
<!-- 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 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>
|
||||
|
||||
|
||||
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 %}
|
||||
@@ -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
|
||||
@@ -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