From 8d1f4bb50e8e8b3bd9c2c66c9b457b47b9c1c819 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 23:56:05 +0000 Subject: [PATCH] Phase 5: Implement Web Dashboard component Add web dashboard with FastAPI and Jinja2 templates for visualizing network status, nodes, messages, and members with an interactive map. Features: - FastAPI app with Jinja2 templating and httpx client for API - Responsive UI using Tailwind CSS with DaisyUI components - Interactive map with Leaflet.js for node visualization - Pages: home, network stats, nodes list/detail, messages, map, members - CLI with extensive configuration (network info, API, members file) - Development mode with uvicorn auto-reload support --- src/meshcore_hub/__main__.py | 63 +----- src/meshcore_hub/web/app.py | 149 +++++++++++++ src/meshcore_hub/web/cli.py | 195 ++++++++++++++++++ src/meshcore_hub/web/routes/__init__.py | 24 ++- src/meshcore_hub/web/routes/home.py | 18 ++ src/meshcore_hub/web/routes/map.py | 77 +++++++ src/meshcore_hub/web/routes/members.py | 59 ++++++ src/meshcore_hub/web/routes/messages.py | 68 ++++++ src/meshcore_hub/web/routes/network.py | 41 ++++ src/meshcore_hub/web/routes/nodes.py | 113 ++++++++++ src/meshcore_hub/web/templates/base.html | 120 +++++++++++ src/meshcore_hub/web/templates/home.html | 118 +++++++++++ src/meshcore_hub/web/templates/map.html | 103 +++++++++ src/meshcore_hub/web/templates/members.html | 90 ++++++++ src/meshcore_hub/web/templates/messages.html | 139 +++++++++++++ src/meshcore_hub/web/templates/network.html | 148 +++++++++++++ .../web/templates/node_detail.html | 154 ++++++++++++++ src/meshcore_hub/web/templates/nodes.html | 138 +++++++++++++ 18 files changed, 1755 insertions(+), 62 deletions(-) create mode 100644 src/meshcore_hub/web/app.py create mode 100644 src/meshcore_hub/web/cli.py create mode 100644 src/meshcore_hub/web/routes/home.py create mode 100644 src/meshcore_hub/web/routes/map.py create mode 100644 src/meshcore_hub/web/routes/members.py create mode 100644 src/meshcore_hub/web/routes/messages.py create mode 100644 src/meshcore_hub/web/routes/network.py create mode 100644 src/meshcore_hub/web/routes/nodes.py create mode 100644 src/meshcore_hub/web/templates/base.html create mode 100644 src/meshcore_hub/web/templates/home.html create mode 100644 src/meshcore_hub/web/templates/map.html create mode 100644 src/meshcore_hub/web/templates/members.html create mode 100644 src/meshcore_hub/web/templates/messages.html create mode 100644 src/meshcore_hub/web/templates/network.html create mode 100644 src/meshcore_hub/web/templates/node_detail.html create mode 100644 src/meshcore_hub/web/templates/nodes.html diff --git a/src/meshcore_hub/__main__.py b/src/meshcore_hub/__main__.py index fd8464d..979eba1 100644 --- a/src/meshcore_hub/__main__.py +++ b/src/meshcore_hub/__main__.py @@ -33,71 +33,12 @@ def cli(ctx: click.Context, log_level: str) -> None: from meshcore_hub.interface.cli import interface from meshcore_hub.collector.cli import collector from meshcore_hub.api.cli import api +from meshcore_hub.web.cli import web cli.add_command(interface) cli.add_command(collector) cli.add_command(api) - - -@cli.command() -@click.option( - "--host", - type=str, - default="0.0.0.0", - envvar="WEB_HOST", - help="Web server host", -) -@click.option( - "--port", - type=int, - default=8080, - envvar="WEB_PORT", - help="Web server port", -) -@click.option( - "--api-url", - type=str, - default="http://localhost:8000", - envvar="API_BASE_URL", - help="API server base URL", -) -@click.option( - "--api-key", - type=str, - default=None, - envvar="API_KEY", - help="API key for queries", -) -@click.option( - "--network-name", - type=str, - default="MeshCore Network", - envvar="NETWORK_NAME", - help="Network display name", -) -@click.option( - "--reload", - is_flag=True, - default=False, - help="Enable auto-reload for development", -) -def web( - host: str, - port: int, - api_url: str, - api_key: str | None, - network_name: str, - reload: bool, -) -> None: - """Run the web dashboard. - - Provides a web interface for visualizing network status. - """ - click.echo("Starting web dashboard...") - click.echo(f"Listening on: {host}:{port}") - click.echo(f"API URL: {api_url}") - click.echo(f"Network name: {network_name}") - click.echo("Web dashboard not yet implemented.") +cli.add_command(web) @cli.group() diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py new file mode 100644 index 0000000..db9a1b9 --- /dev/null +++ b/src/meshcore_hub/web/app.py @@ -0,0 +1,149 @@ +"""FastAPI application for MeshCore Hub Web Dashboard.""" + +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncGenerator + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from meshcore_hub import __version__ + +logger = logging.getLogger(__name__) + +# Directory paths +PACKAGE_DIR = Path(__file__).parent +TEMPLATES_DIR = PACKAGE_DIR / "templates" +STATIC_DIR = PACKAGE_DIR / "static" + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan handler.""" + # Create HTTP client for API calls + api_url = getattr(app.state, "api_url", "http://localhost:8000") + api_key = getattr(app.state, "api_key", None) + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + app.state.http_client = httpx.AsyncClient( + base_url=api_url, + headers=headers, + timeout=30.0, + ) + + logger.info(f"Web dashboard started, API URL: {api_url}") + + yield + + # Cleanup + await app.state.http_client.aclose() + logger.info("Web dashboard stopped") + + +def create_app( + api_url: str = "http://localhost:8000", + api_key: str | None = None, + network_name: str = "MeshCore Network", + network_city: str | None = None, + network_country: str | None = None, + network_location: tuple[float, float] | None = None, + network_radio_config: str | None = None, + network_contact_email: str | None = None, + network_contact_discord: str | None = None, + members_file: str | None = None, +) -> FastAPI: + """Create and configure the web dashboard application. + + Args: + api_url: Base URL of the MeshCore Hub API + api_key: API key for authentication + network_name: Display name for the network + network_city: City where the network is located + network_country: Country where the network is located + network_location: (lat, lon) tuple for map centering + network_radio_config: Radio configuration description + network_contact_email: Contact email address + network_contact_discord: Discord invite/server info + members_file: Path to members JSON file + + Returns: + Configured FastAPI application + """ + app = FastAPI( + title="MeshCore Hub Dashboard", + description="Web dashboard for MeshCore network visualization", + version=__version__, + lifespan=lifespan, + docs_url=None, # Disable docs for web app + redoc_url=None, + ) + + # Store configuration in app state + app.state.api_url = api_url + app.state.api_key = api_key + app.state.network_name = network_name + app.state.network_city = network_city + app.state.network_country = network_country + app.state.network_location = network_location or (0.0, 0.0) + app.state.network_radio_config = network_radio_config + app.state.network_contact_email = network_contact_email + app.state.network_contact_discord = network_contact_discord + app.state.members_file = members_file + + # Set up templates + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + app.state.templates = templates + + # Mount static files + if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + # Include routers + from meshcore_hub.web.routes import web_router + + app.include_router(web_router) + + # Health check endpoint + @app.get("/health", tags=["Health"]) + async def health() -> dict: + """Basic health check.""" + return {"status": "healthy", "version": __version__} + + @app.get("/health/ready", tags=["Health"]) + async def health_ready(request: Request) -> dict: + """Readiness check including API connectivity.""" + try: + response = await request.app.state.http_client.get("/health") + if response.status_code == 200: + return {"status": "ready", "api": "connected"} + return {"status": "not_ready", "api": f"status {response.status_code}"} + except Exception as e: + return {"status": "not_ready", "api": str(e)} + + return app + + +def get_templates(request: Request) -> Jinja2Templates: + """Get templates from app state.""" + return request.app.state.templates + + +def get_network_context(request: Request) -> dict: + """Get network configuration context for templates.""" + return { + "network_name": request.app.state.network_name, + "network_city": request.app.state.network_city, + "network_country": request.app.state.network_country, + "network_location": request.app.state.network_location, + "network_radio_config": request.app.state.network_radio_config, + "network_contact_email": request.app.state.network_contact_email, + "network_contact_discord": request.app.state.network_contact_discord, + "version": __version__, + } diff --git a/src/meshcore_hub/web/cli.py b/src/meshcore_hub/web/cli.py new file mode 100644 index 0000000..eb8a196 --- /dev/null +++ b/src/meshcore_hub/web/cli.py @@ -0,0 +1,195 @@ +"""Web dashboard CLI commands.""" + +import click + + +@click.command() +@click.option( + "--host", + type=str, + default="0.0.0.0", + envvar="WEB_HOST", + help="Web server host", +) +@click.option( + "--port", + type=int, + default=8080, + envvar="WEB_PORT", + help="Web server port", +) +@click.option( + "--api-url", + type=str, + default="http://localhost:8000", + envvar="API_BASE_URL", + help="API server base URL", +) +@click.option( + "--api-key", + type=str, + default=None, + envvar="API_KEY", + help="API key for queries", +) +@click.option( + "--network-name", + type=str, + default="MeshCore Network", + envvar="NETWORK_NAME", + help="Network display name", +) +@click.option( + "--network-city", + type=str, + default=None, + envvar="NETWORK_CITY", + help="Network city location", +) +@click.option( + "--network-country", + type=str, + default=None, + envvar="NETWORK_COUNTRY", + help="Network country", +) +@click.option( + "--network-lat", + type=float, + default=0.0, + envvar="NETWORK_LAT", + help="Network center latitude", +) +@click.option( + "--network-lon", + type=float, + default=0.0, + envvar="NETWORK_LON", + help="Network center longitude", +) +@click.option( + "--network-radio-config", + type=str, + default=None, + envvar="NETWORK_RADIO_CONFIG", + help="Radio configuration description", +) +@click.option( + "--network-contact-email", + type=str, + default=None, + envvar="NETWORK_CONTACT_EMAIL", + help="Contact email address", +) +@click.option( + "--network-contact-discord", + type=str, + default=None, + envvar="NETWORK_CONTACT_DISCORD", + help="Discord server info", +) +@click.option( + "--members-file", + type=str, + default=None, + envvar="MEMBERS_FILE", + help="Path to members JSON file", +) +@click.option( + "--reload", + is_flag=True, + default=False, + help="Enable auto-reload for development", +) +@click.pass_context +def web( + ctx: click.Context, + host: str, + port: int, + api_url: str, + api_key: str | None, + network_name: str, + network_city: str | None, + network_country: str | None, + network_lat: float, + network_lon: float, + network_radio_config: str | None, + network_contact_email: str | None, + network_contact_discord: str | None, + members_file: str | None, + reload: bool, +) -> None: + """Run the web dashboard. + + Provides a web interface for visualizing network status, browsing nodes, + viewing messages, and displaying a node map. + + Examples: + + # Run with defaults + meshcore-hub web + + # Run with custom network name and location + meshcore-hub web --network-name "My Mesh" --network-city "New York" --network-country "USA" + + # Run with API authentication + meshcore-hub web --api-url http://api.example.com --api-key secret + + # Run with members file + meshcore-hub web --members-file /path/to/members.json + + # Development mode with auto-reload + meshcore-hub web --reload + """ + import uvicorn + + from meshcore_hub.web.app import create_app + + click.echo("=" * 50) + click.echo("MeshCore Hub Web Dashboard") + click.echo("=" * 50) + click.echo(f"Host: {host}") + click.echo(f"Port: {port}") + click.echo(f"API URL: {api_url}") + click.echo(f"API key configured: {api_key is not None}") + click.echo(f"Network: {network_name}") + if network_city and network_country: + click.echo(f"Location: {network_city}, {network_country}") + if network_lat != 0.0 or network_lon != 0.0: + click.echo(f"Map center: {network_lat}, {network_lon}") + if members_file: + click.echo(f"Members file: {members_file}") + click.echo(f"Reload mode: {reload}") + click.echo("=" * 50) + + network_location = (network_lat, network_lon) + + if reload: + # For development, use uvicorn's reload feature + click.echo("\nStarting in development mode with auto-reload...") + click.echo("Note: Using default settings for reload mode.") + + uvicorn.run( + "meshcore_hub.web.app:create_app", + host=host, + port=port, + reload=True, + factory=True, + ) + else: + # For production, create app directly + app = create_app( + api_url=api_url, + api_key=api_key, + network_name=network_name, + network_city=network_city, + network_country=network_country, + network_location=network_location, + network_radio_config=network_radio_config, + network_contact_email=network_contact_email, + network_contact_discord=network_contact_discord, + members_file=members_file, + ) + + click.echo("\nStarting web dashboard...") + uvicorn.run(app, host=host, port=port) diff --git a/src/meshcore_hub/web/routes/__init__.py b/src/meshcore_hub/web/routes/__init__.py index 1ad30fc..4d35f07 100644 --- a/src/meshcore_hub/web/routes/__init__.py +++ b/src/meshcore_hub/web/routes/__init__.py @@ -1 +1,23 @@ -"""Web dashboard route handlers.""" +"""Web routes for MeshCore Hub Dashboard.""" + +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.nodes import router as nodes_router +from meshcore_hub.web.routes.messages import router as messages_router +from meshcore_hub.web.routes.map import router as map_router +from meshcore_hub.web.routes.members import router as members_router + +# Create main web router +web_router = APIRouter() + +# Include all sub-routers +web_router.include_router(home_router) +web_router.include_router(network_router) +web_router.include_router(nodes_router) +web_router.include_router(messages_router) +web_router.include_router(map_router) +web_router.include_router(members_router) + +__all__ = ["web_router"] diff --git a/src/meshcore_hub/web/routes/home.py b/src/meshcore_hub/web/routes/home.py new file mode 100644 index 0000000..03a37cf --- /dev/null +++ b/src/meshcore_hub/web/routes/home.py @@ -0,0 +1,18 @@ +"""Home page route.""" + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + """Render the home page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + return templates.TemplateResponse("home.html", context) diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py new file mode 100644 index 0000000..f3af646 --- /dev/null +++ b/src/meshcore_hub/web/routes/map.py @@ -0,0 +1,77 @@ +"""Map page route.""" + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/map", response_class=HTMLResponse) +async def map_page(request: Request) -> HTMLResponse: + """Render the map page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + return templates.TemplateResponse("map.html", context) + + +@router.get("/map/data") +async def map_data(request: Request) -> JSONResponse: + """Return node location data as JSON for the map.""" + nodes_with_location = [] + + try: + # Fetch all nodes from API + response = await request.app.state.http_client.get( + "/api/v1/nodes", params={"limit": 500} + ) + if response.status_code == 200: + data = response.json() + nodes = data.get("items", []) + + # Filter nodes with location tags + for node in nodes: + tags = node.get("tags", []) + lat = None + lon = None + for tag in tags: + if tag.get("key") == "lat": + try: + lat = float(tag.get("value")) + except (ValueError, TypeError): + pass + elif tag.get("key") == "lon": + try: + lon = float(tag.get("value")) + except (ValueError, TypeError): + pass + + if lat is not None and lon is not None: + nodes_with_location.append({ + "public_key": node.get("public_key"), + "name": node.get("name") or node.get("public_key", "")[:12], + "adv_type": node.get("adv_type"), + "lat": lat, + "lon": lon, + "last_seen": node.get("last_seen"), + }) + + except Exception as e: + logger.warning(f"Failed to fetch nodes for map: {e}") + + # Get network center location + network_location = request.app.state.network_location + + return JSONResponse({ + "nodes": nodes_with_location, + "center": { + "lat": network_location[0], + "lon": network_location[1], + }, + }) diff --git a/src/meshcore_hub/web/routes/members.py b/src/meshcore_hub/web/routes/members.py new file mode 100644 index 0000000..f730a05 --- /dev/null +++ b/src/meshcore_hub/web/routes/members.py @@ -0,0 +1,59 @@ +"""Members page route.""" + +import json +import logging +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def load_members(members_file: str | None) -> list[dict]: + """Load members from JSON file. + + Args: + members_file: Path to members JSON file + + Returns: + List of member dictionaries + """ + if not members_file: + return [] + + try: + path = Path(members_file) + if path.exists(): + with open(path, "r") as f: + data = json.load(f) + # Handle both list and dict with "members" key + if isinstance(data, list): + return data + elif isinstance(data, dict) and "members" in data: + return data["members"] + else: + logger.warning(f"Members file not found: {members_file}") + except Exception as e: + logger.error(f"Failed to load members file: {e}") + + return [] + + +@router.get("/members", response_class=HTMLResponse) +async def members_page(request: Request) -> HTMLResponse: + """Render the members page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Load members from file + members_file = request.app.state.members_file + members = load_members(members_file) + + context["members"] = members + + return templates.TemplateResponse("members.html", context) diff --git a/src/meshcore_hub/web/routes/messages.py b/src/meshcore_hub/web/routes/messages.py new file mode 100644 index 0000000..8befb89 --- /dev/null +++ b/src/meshcore_hub/web/routes/messages.py @@ -0,0 +1,68 @@ +"""Messages page route.""" + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/messages", response_class=HTMLResponse) +async def messages_list( + request: Request, + message_type: str | None = Query(None, description="Filter by message type"), + channel_idx: int | None = Query(None, description="Filter by channel"), + search: str | None = Query(None, description="Search in message text"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(50, ge=1, le=100, description="Items per page"), +) -> HTMLResponse: + """Render the messages list page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Calculate offset + offset = (page - 1) * limit + + # Build query params + params = {"limit": limit, "offset": offset} + if message_type: + params["message_type"] = message_type + if channel_idx is not None: + params["channel_idx"] = channel_idx + + # Fetch messages from API + messages = [] + total = 0 + + try: + response = await request.app.state.http_client.get( + "/api/v1/messages", params=params + ) + if response.status_code == 200: + data = response.json() + messages = data.get("items", []) + total = data.get("total", 0) + except Exception as e: + logger.warning(f"Failed to fetch messages from API: {e}") + context["api_error"] = str(e) + + # Calculate pagination + total_pages = (total + limit - 1) // limit if total > 0 else 1 + + context.update({ + "messages": messages, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "message_type": message_type or "", + "channel_idx": channel_idx, + "search": search or "", + }) + + return templates.TemplateResponse("messages.html", context) diff --git a/src/meshcore_hub/web/routes/network.py b/src/meshcore_hub/web/routes/network.py new file mode 100644 index 0000000..74f1c18 --- /dev/null +++ b/src/meshcore_hub/web/routes/network.py @@ -0,0 +1,41 @@ +"""Network overview page route.""" + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/network", response_class=HTMLResponse) +async def network_overview(request: Request) -> HTMLResponse: + """Render the network overview page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Fetch stats from API + stats = { + "total_nodes": 0, + "active_nodes": 0, + "total_messages": 0, + "messages_today": 0, + "total_advertisements": 0, + "channel_message_counts": {}, + } + + try: + response = await request.app.state.http_client.get("/api/v1/dashboard/stats") + if response.status_code == 200: + stats = response.json() + except Exception as e: + logger.warning(f"Failed to fetch stats from API: {e}") + context["api_error"] = str(e) + + context["stats"] = stats + + return templates.TemplateResponse("network.html", context) diff --git a/src/meshcore_hub/web/routes/nodes.py b/src/meshcore_hub/web/routes/nodes.py new file mode 100644 index 0000000..e85870d --- /dev/null +++ b/src/meshcore_hub/web/routes/nodes.py @@ -0,0 +1,113 @@ +"""Nodes page routes.""" + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/nodes", response_class=HTMLResponse) +async def nodes_list( + request: Request, + search: str | None = Query(None, description="Search term"), + adv_type: str | None = Query(None, description="Filter by node type"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(20, ge=1, le=100, description="Items per page"), +) -> HTMLResponse: + """Render the nodes list page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Calculate offset + offset = (page - 1) * limit + + # Build query params + params = {"limit": limit, "offset": offset} + if search: + params["search"] = search + if adv_type: + params["adv_type"] = adv_type + + # Fetch nodes from API + nodes = [] + total = 0 + + try: + response = await request.app.state.http_client.get( + "/api/v1/nodes", params=params + ) + if response.status_code == 200: + data = response.json() + nodes = data.get("items", []) + total = data.get("total", 0) + except Exception as e: + logger.warning(f"Failed to fetch nodes from API: {e}") + context["api_error"] = str(e) + + # Calculate pagination + total_pages = (total + limit - 1) // limit if total > 0 else 1 + + context.update({ + "nodes": nodes, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "search": search or "", + "adv_type": adv_type or "", + }) + + return templates.TemplateResponse("nodes.html", context) + + +@router.get("/nodes/{public_key}", response_class=HTMLResponse) +async def node_detail(request: Request, public_key: str) -> HTMLResponse: + """Render the node detail page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + node = None + advertisements = [] + telemetry = [] + + try: + # Fetch node details + response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}") + if response.status_code == 200: + node = response.json() + + # Fetch recent advertisements for this node + response = await request.app.state.http_client.get( + "/api/v1/advertisements", + params={"public_key": public_key, "limit": 10} + ) + if response.status_code == 200: + advertisements = response.json().get("items", []) + + # Fetch recent telemetry for this node + response = await request.app.state.http_client.get( + "/api/v1/telemetry", + params={"node_public_key": public_key, "limit": 10} + ) + if response.status_code == 200: + telemetry = response.json().get("items", []) + + except Exception as e: + logger.warning(f"Failed to fetch node details from API: {e}") + context["api_error"] = str(e) + + context.update({ + "node": node, + "advertisements": advertisements, + "telemetry": telemetry, + "public_key": public_key, + }) + + return templates.TemplateResponse("node_detail.html", context) diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html new file mode 100644 index 0000000..816cc3e --- /dev/null +++ b/src/meshcore_hub/web/templates/base.html @@ -0,0 +1,120 @@ + + + + + + {% block title %}{{ network_name }}{% endblock %} + + + + + + + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/src/meshcore_hub/web/templates/home.html b/src/meshcore_hub/web/templates/home.html new file mode 100644 index 0000000..d52b8f2 --- /dev/null +++ b/src/meshcore_hub/web/templates/home.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Home{% endblock %} + +{% block content %} +
+
+
+

{{ network_name }}

+ {% if network_city and network_country %} +

{{ network_city }}, {{ network_country }}

+ {% endif %} +

+ Welcome to the {{ network_name }} mesh network dashboard. + Monitor network activity, view connected nodes, and explore message history. +

+ +
+
+
+ +
+ +
+
+

+ + + + Network Info +

+
+ {% if network_radio_config %} +
+ Radio Config: + {{ network_radio_config }} +
+ {% endif %} + {% if network_location and network_location != (0.0, 0.0) %} +
+ Location: + {{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }} +
+ {% endif %} +
+
+
+ + +
+
+

+ + + + Quick Links +

+ +
+
+ + +
+
+

+ + + + Contact +

+
+ {% if network_contact_email %} + + + + + {{ network_contact_email }} + + {% endif %} + {% if network_contact_discord %} +
+ + + + {{ network_contact_discord }} +
+ {% endif %} + {% if not network_contact_email and not network_contact_discord %} +

No contact information configured.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/meshcore_hub/web/templates/map.html b/src/meshcore_hub/web/templates/map.html new file mode 100644 index 0000000..0480b14 --- /dev/null +++ b/src/meshcore_hub/web/templates/map.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Node Map{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+

Node Map

+ Loading... +
+ +
+
+
+
+
+ +
+

Nodes are placed on the map based on their lat and lon tags.

+

To add a node to the map, set its location tags via the API.

+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/src/meshcore_hub/web/templates/members.html b/src/meshcore_hub/web/templates/members.html new file mode 100644 index 0000000..6518583 --- /dev/null +++ b/src/meshcore_hub/web/templates/members.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Members{% endblock %} + +{% block content %} +
+

Network Members

+ {{ members|length }} members +
+ +{% if members %} +
+ {% for member in members %} +
+
+

+ {{ member.name }} + {% if member.callsign %} + {{ member.callsign }} + {% endif %} +

+ + {% if member.role %} +

{{ member.role }}

+ {% endif %} + + {% if member.description %} +

{{ member.description }}

+ {% endif %} + + {% if member.email or member.discord or member.website %} +
+ {% if member.email %} + + + + + Email + + {% endif %} + {% if member.website %} + + + + + Website + + {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+ + + +
+

No members configured

+

To display network members, provide a members JSON file using the --members-file option.

+
+
+ +
+
+

Members File Format

+

Create a JSON file with the following structure:

+
{
+  "members": [
+    {
+      "name": "John Doe",
+      "callsign": "AB1CD",
+      "role": "Network Admin",
+      "description": "Manages the main repeater node.",
+      "email": "john@example.com",
+      "website": "https://example.com"
+    },
+    {
+      "name": "Jane Smith",
+      "role": "Member",
+      "description": "Regular user in the downtown area."
+    }
+  ]
+}
+
+
+{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html new file mode 100644 index 0000000..93f478b --- /dev/null +++ b/src/meshcore_hub/web/templates/messages.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Messages{% endblock %} + +{% block content %} +
+

Messages

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + + + {% for msg in messages %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeTypeFrom/ChannelMessageSNRHops
+ {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} + + {% if msg.message_type == 'channel' %} + Channel + {% else %} + Direct + {% endif %} + + {% if msg.message_type == 'channel' %} + CH{{ msg.channel_idx }} + {% else %} + {{ (msg.pubkey_prefix or '-')[:12] }} + {% endif %} + + {{ msg.text or '-' }} + + {% if msg.snr is not none %} + {{ "%.1f"|format(msg.snr) }} + {% else %} + - + {% endif %} + + {% if msg.hops is not none %} + {{ msg.hops }} + {% else %} + - + {% endif %} +
No messages found.
+
+ + +{% if total_pages > 1 %} +
+
+ {% if page > 1 %} + Previous + {% else %} + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + + {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} + {{ p }} + {% elif p == 2 or p == total_pages - 1 %} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + Next + {% else %} + + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/network.html b/src/meshcore_hub/web/templates/network.html new file mode 100644 index 0000000..44bebd1 --- /dev/null +++ b/src/meshcore_hub/web/templates/network.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Network Overview{% endblock %} + +{% block content %} +
+

Network Overview

+ +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+ +
+
+ + + +
+
Total Nodes
+
{{ stats.total_nodes }}
+
All discovered nodes
+
+ + +
+
+ + + +
+
Active Nodes
+
{{ stats.active_nodes }}
+
Active in last 24 hours
+
+ + +
+
+ + + +
+
Total Messages
+
{{ stats.total_messages }}
+
All time
+
+ + +
+
+ + + +
+
Messages Today
+
{{ stats.messages_today }}
+
Last 24 hours
+
+
+ + +
+ +
+
+

+ + + + Advertisements +

+
{{ stats.total_advertisements }}
+

Total advertisements received

+
+
+ + +
+
+

+ + + + Channel Messages +

+ {% if stats.channel_message_counts %} +
+ + + + + + + + + {% for channel, count in stats.channel_message_counts.items() %} + + + + + {% endfor %} + +
ChannelCount
Channel {{ channel }}{{ count }}
+
+ {% else %} +

No channel messages recorded yet.

+ {% endif %} +
+
+
+ + +
+ + + + + Browse Nodes + + + + + + View Messages + + + + + + View Map + +
+{% endblock %} diff --git a/src/meshcore_hub/web/templates/node_detail.html b/src/meshcore_hub/web/templates/node_detail.html new file mode 100644 index 0000000..defacff --- /dev/null +++ b/src/meshcore_hub/web/templates/node_detail.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Node Details{% endblock %} + +{% block content %} + + +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + +{% if node %} + +
+
+

+ {{ node.name or 'Unnamed Node' }} + {% if node.adv_type %} + {{ node.adv_type }} + {% endif %} +

+ +
+
+

Public Key

+ {{ node.public_key }} +
+
+

Activity

+
+

First seen: {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}

+

Last seen: {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}

+
+
+
+ + + {% if node.tags %} +
+

Tags

+
+ + + + + + + + + + {% for tag in node.tags %} + + + + + + {% endfor %} + +
KeyValueType
{{ tag.key }}{{ tag.value }}{{ tag.value_type or 'string' }}
+
+
+ {% endif %} +
+
+ +
+ +
+
+

Recent Advertisements

+ {% if advertisements %} +
+ + + + + + + + + + {% for adv in advertisements %} + + + + + + {% endfor %} + +
TimeTypeName
{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}{{ adv.adv_type or '-' }}{{ adv.name or '-' }}
+
+ {% else %} +

No advertisements recorded.

+ {% endif %} +
+
+ + +
+
+

Recent Telemetry

+ {% if telemetry %} +
+ + + + + + + + + {% for tel in telemetry %} + + + + + {% endfor %} + +
TimeData
{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }} + {% if tel.parsed_data %} + {{ tel.parsed_data | tojson }} + {% else %} + - + {% endif %} +
+
+ {% else %} +

No telemetry recorded.

+ {% endif %} +
+
+
+ +{% else %} +
+ + + + Node not found: {{ public_key }} +
+Back to Nodes +{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/nodes.html b/src/meshcore_hub/web/templates/nodes.html new file mode 100644 index 0000000..e47fa1f --- /dev/null +++ b/src/meshcore_hub/web/templates/nodes.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Nodes{% endblock %} + +{% block content %} +
+

Nodes

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + + + {% for node in nodes %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
NamePublic KeyTypeLast SeenTags
{{ node.name or '-' }} + {{ node.public_key[:16] }}... + + {% if node.adv_type %} + {{ node.adv_type }} + {% else %} + - + {% endif %} + + {% if node.last_seen %} + {{ node.last_seen[:19].replace('T', ' ') }} + {% else %} + - + {% endif %} + + {% if node.tags %} +
+ {% for tag in node.tags[:3] %} + {{ tag.key }} + {% endfor %} + {% if node.tags|length > 3 %} + +{{ node.tags|length - 3 }} + {% endif %} +
+ {% else %} + - + {% endif %} +
+ + View + +
No nodes found.
+
+ + +{% if total_pages > 1 %} +
+
+ {% if page > 1 %} + Previous + {% else %} + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + + {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} + {{ p }} + {% elif p == 2 or p == total_pages - 1 %} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + Next + {% else %} + + {% endif %} +
+
+{% endif %} +{% endblock %}