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
This commit is contained in:
Claude
2025-12-02 23:56:05 +00:00
parent aefa9b735f
commit 8d1f4bb50e
18 changed files with 1755 additions and 62 deletions

View File

@@ -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()

149
src/meshcore_hub/web/app.py Normal file
View File

@@ -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__,
}

195
src/meshcore_hub/web/cli.py Normal file
View File

@@ -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)

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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],
},
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ network_name }}{% endblock %}</title>
<!-- Tailwind CSS with DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* Table styling */
.table-compact td, .table-compact th {
padding: 0.5rem 0.75rem;
}
/* Truncate text in table cells */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen bg-base-200">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</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="/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>
</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>
{{ 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="/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>
</ul>
</div>
<div class="navbar-end">
<div class="badge badge-outline badge-sm">v{{ version }}</div>
</div>
</div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
<aside>
<p>
{{ network_name }}
{% if network_city and network_country %}
- {{ network_city }}, {{ network_country }}
{% endif %}
</p>
<p class="text-sm opacity-70">
{% if network_contact_email %}
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
{% endif %}
{% if network_contact_email and network_contact_discord %} | {% endif %}
{% if network_contact_discord %}
<span>Discord: {{ network_contact_discord }}</span>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">Powered by MeshCore Hub v{{ version }}</p>
</aside>
</footer>
<!-- Leaflet JS for maps -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Home{% endblock %}
{% block content %}
<div class="hero min-h-[50vh] bg-base-100 rounded-box">
<div class="hero-content text-center">
<div class="max-w-2xl">
<h1 class="text-5xl font-bold">{{ network_name }}</h1>
{% if network_city and network_country %}
<p class="py-2 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
{% endif %}
<p class="py-6">
Welcome to the {{ network_name }} mesh network dashboard.
Monitor network activity, view connected nodes, and explore message history.
</p>
<div class="flex gap-4 justify-center flex-wrap">
<a href="/network" 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="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>
View Network Stats
</a>
<a href="/nodes" 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="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>
Browse Nodes
</a>
<a href="/map" 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="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>
View Map
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<!-- Network Info Card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Network Info
</h2>
<div class="space-y-2">
{% if network_radio_config %}
<div class="flex justify-between">
<span class="opacity-70">Radio Config:</span>
<span class="font-mono">{{ network_radio_config }}</span>
</div>
{% endif %}
{% if network_location and network_location != (0.0, 0.0) %}
<div class="flex justify-between">
<span class="opacity-70">Location:</span>
<span class="font-mono">{{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Quick Links Card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Quick Links
</h2>
<ul class="menu bg-base-200 rounded-box">
<li><a href="/messages">Recent Messages</a></li>
<li><a href="/nodes">All Nodes</a></li>
<li><a href="/members">Network Members</a></li>
</ul>
</div>
</div>
<!-- Contact Card -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Contact
</h2>
<div class="space-y-2">
{% if network_contact_email %}
<a href="mailto:{{ network_contact_email }}" class="btn btn-outline btn-sm btn-block">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
{{ network_contact_email }}
</a>
{% endif %}
{% if network_contact_discord %}
<div class="btn btn-outline btn-sm btn-block">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</svg>
{{ network_contact_discord }}
</div>
{% endif %}
{% if not network_contact_email and not network_contact_discord %}
<p class="text-sm opacity-70">No contact information configured.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Map{% endblock %}
{% block extra_head %}
<style>
#map {
height: calc(100vh - 250px);
min-height: 400px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Node Map</h1>
<span id="node-count" class="badge badge-lg">Loading...</span>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-2">
<div id="map"></div>
</div>
</div>
<div class="mt-4 text-sm opacity-70">
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
<p>To add a node to the map, set its location tags via the API.</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Initialize map
const map = L.map('map').setView([{{ network_location[0] }}, {{ network_location[1] }}], 10);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Custom marker icon
const nodeIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color: oklch(var(--p)); width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6]
});
// Fetch and display nodes
fetch('/map/data')
.then(response => response.json())
.then(data => {
const nodes = data.nodes;
const center = data.center;
// Update node count
document.getElementById('node-count').textContent = `${nodes.length} nodes on map`;
// Add markers for each node
nodes.forEach(node => {
const marker = L.marker([node.lat, node.lon], { icon: nodeIcon }).addTo(map);
// Create popup content
const popupContent = `
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${node.name}</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">Type:</span> ${node.adv_type || 'Unknown'}</p>
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
</div>
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
</div>
`;
marker.bindPopup(popupContent);
});
// Fit bounds if we have nodes
if (nodes.length > 0) {
const bounds = L.latLngBounds(nodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [50, 50] });
} else if (center.lat !== 0 || center.lon !== 0) {
// Use network center if no nodes
map.setView([center.lat, center.lon], 10);
}
})
.catch(error => {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
</script>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Members{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Members</h1>
<span class="badge badge-lg">{{ members|length }} members</span>
</div>
{% if members %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for member in members %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
{{ member.name }}
{% if member.callsign %}
<span class="badge badge-secondary">{{ member.callsign }}</span>
{% endif %}
</h2>
{% if member.role %}
<p class="text-sm opacity-70">{{ member.role }}</p>
{% endif %}
{% if member.description %}
<p class="mt-2">{{ member.description }}</p>
{% endif %}
{% if member.email or member.discord or member.website %}
<div class="card-actions justify-start mt-4">
{% if member.email %}
<a href="mailto:{{ member.email }}" class="btn btn-ghost btn-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Email
</a>
{% endif %}
{% if member.website %}
<a href="{{ member.website }}" target="_blank" class="btn btn-ghost btn-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Website
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, provide a members JSON file using the <code>--members-file</code> option.</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Members File Format</h2>
<p class="mb-4">Create a JSON file with the following structure:</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>{
"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."
}
]
}</code></pre>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Messages{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Messages</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
</label>
<select name="message_type" class="select select-bordered select-sm">
<option value="">All Types</option>
<option value="direct" {% if message_type == 'direct' %}selected{% endif %}>Direct</option>
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Channel</span>
</label>
<select name="channel_idx" class="select select-bordered select-sm">
<option value="">All Channels</option>
{% for i in range(8) %}
<option value="{{ i }}" {% if channel_idx == i %}selected{% endif %}>Channel {{ i }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
</div>
<!-- Messages Table -->
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>From/Channel</th>
<th>Message</th>
<th>SNR</th>
<th>Hops</th>
</tr>
</thead>
<tbody>
{% for msg in messages %}
<tr class="hover">
<td class="text-xs whitespace-nowrap">
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
</td>
<td>
{% if msg.message_type == 'channel' %}
<span class="badge badge-info badge-sm">Channel</span>
{% else %}
<span class="badge badge-success badge-sm">Direct</span>
{% endif %}
</td>
<td class="font-mono text-xs">
{% if msg.message_type == 'channel' %}
CH{{ msg.channel_idx }}
{% else %}
{{ (msg.pubkey_prefix or '-')[:12] }}
{% endif %}
</td>
<td class="truncate-cell" title="{{ msg.text }}">
{{ msg.text or '-' }}
</td>
<td class="text-center">
{% if msg.snr is not none %}
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
{% else %}
-
{% endif %}
</td>
<td class="text-center">
{% if msg.hops is not none %}
<span class="badge badge-ghost badge-sm">{{ msg.hops }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Network Overview{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Overview</h1>
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- 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>
<!-- Active Nodes -->
<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="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<div class="stat-title">Active Nodes</div>
<div class="stat-value text-secondary">{{ stats.active_nodes }}</div>
<div class="stat-desc">Active in last 24 hours</div>
</div>
<!-- Total Messages -->
<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">Total Messages</div>
<div class="stat-value text-accent">{{ stats.total_messages }}</div>
<div class="stat-desc">All time</div>
</div>
<!-- Messages Today -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-info">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title">Messages Today</div>
<div class="stat-value text-info">{{ stats.messages_today }}</div>
<div class="stat-desc">Last 24 hours</div>
</div>
</div>
<!-- Additional Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Advertisements -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Advertisements
</h2>
<div class="stat-value">{{ stats.total_advertisements }}</div>
<p class="text-sm opacity-70">Total advertisements received</p>
</div>
</div>
<!-- Channel Stats -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
Channel Messages
</h2>
{% if stats.channel_message_counts %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Channel</th>
<th class="text-right">Count</th>
</tr>
</thead>
<tbody>
{% for channel, count in stats.channel_message_counts.items() %}
<tr>
<td>Channel {{ channel }}</td>
<td class="text-right font-mono">{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70">No channel messages recorded yet.</p>
{% endif %}
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-4 mt-8 flex-wrap">
<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>
Browse Nodes
</a>
<a href="/messages" 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="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>
View Messages
</a>
<a href="/map" 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="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>
View Map
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block content %}
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
<li>{{ node.name or public_key[:12] + '...' if node else 'Not Found' }}</li>
</ul>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
{% if node %}
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{{ node.name or 'Unnamed Node' }}
{% if node.adv_type %}
<span class="badge badge-secondary">{{ node.adv_type }}</span>
{% endif %}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
</div>
<div>
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
</div>
</div>
<!-- Tags -->
{% if node.tags %}
<div class="mt-6">
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Recent Advertisements -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Recent Advertisements</h2>
{% if advertisements %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for adv in advertisements %}
<tr>
<td class="text-xs">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
<td>{{ adv.adv_type or '-' }}</td>
<td>{{ adv.name or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="opacity-70">No advertisements recorded.</p>
{% endif %}
</div>
</div>
<!-- Recent Telemetry -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Recent Telemetry</h2>
{% if telemetry %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Time</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for tel in telemetry %}
<tr>
<td class="text-xs">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
<td class="text-xs font-mono">
{% if tel.parsed_data %}
{{ tel.parsed_data | tojson }}
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="opacity-70">No telemetry recorded.</p>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Node not found: {{ public_key }}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Nodes{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Nodes</h1>
<span class="badge badge-lg">{{ total }} total</span>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
</label>
<input type="text" name="search" value="{{ search }}" placeholder="Name or public key..." class="input input-bordered input-sm w-64" />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
</label>
<select name="adv_type" class="select select-bordered select-sm">
<option value="">All Types</option>
<option value="chat" {% if adv_type == 'chat' %}selected{% endif %}>Chat</option>
<option value="repeater" {% if adv_type == 'repeater' %}selected{% endif %}>Repeater</option>
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
</div>
<!-- Nodes Table -->
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
<table class="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>Public Key</th>
<th>Type</th>
<th>Last Seen</th>
<th>Tags</th>
<th></th>
</tr>
</thead>
<tbody>
{% for node in nodes %}
<tr class="hover">
<td class="font-medium">{{ node.name or '-' }}</td>
<td class="font-mono text-xs truncate-cell" title="{{ node.public_key }}">
{{ node.public_key[:16] }}...
</td>
<td>
{% if node.adv_type %}
<span class="badge badge-outline badge-sm">{{ node.adv_type }}</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-sm">
{% if node.last_seen %}
{{ node.last_seen[:19].replace('T', ' ') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if node.tags %}
<div class="flex gap-1 flex-wrap">
{% for tag in node.tags[:3] %}
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
{% endfor %}
{% if node.tags|length > 3 %}
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 3 }}</span>
{% endif %}
</div>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td>
<a href="/nodes/{{ node.public_key }}" class="btn btn-ghost btn-xs">
View
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-8 opacity-70">No nodes found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="?page={{ p }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<button class="join-item btn btn-sm btn-disabled">...</button>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}