diff --git a/Dockerfile b/Dockerfile index 5107ac4..85626a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,7 +78,7 @@ USER meshai VOLUME ["/data"] # Expose ttyd web config port -EXPOSE 7682 +EXPOSE 7682 8080 # Health check - verify bot process is alive via PID file HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ diff --git a/config.example.yaml b/config.example.yaml index 66e5677..0e2eb52 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,105 +1,105 @@ -# MeshAI Configuration -# LLM-powered Meshtastic assistant -# -# Copy this to config.yaml and customize as needed -# For Docker: mount as /data/config.yaml - -# === BOT IDENTITY === -bot: - name: ai # Bot's display name - owner: "" # Owner's callsign (optional) - respond_to_dms: true # Respond to direct messages - filter_bbs_protocols: true # Ignore advBBS sync/notification messages - -# === MESHTASTIC CONNECTION === -connection: - type: tcp # serial | tcp - serial_port: /dev/ttyUSB0 # For serial connection - tcp_host: localhost # For TCP connection (meshtasticd) - tcp_port: 4403 - -# === RESPONSE BEHAVIOR === -response: - delay_min: 2.2 # Min delay before responding (seconds) - delay_max: 3.0 # Max delay before responding - max_length: 200 # Max chars per message chunk - max_messages: 3 # Max message chunks per response - -# === CONVERSATION HISTORY === -history: - database: /data/conversations.db - max_messages_per_user: 50 # Messages to keep per user - conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) - auto_cleanup: true # Auto-delete old conversations - cleanup_interval_hours: 24 # How often to run cleanup - max_age_days: 30 # Delete conversations older than this - -# === MEMORY OPTIMIZATION === -memory: - enabled: true # Enable rolling summary memory - window_size: 4 # Recent message pairs to keep in full - summarize_threshold: 8 # Messages before re-summarizing - -# === MESH CONTEXT === -context: - enabled: true # Observe channel traffic for LLM context - observe_channels: [] # Channel indices to observe (empty = all) - ignore_nodes: [] # Node IDs to exclude from observation - max_age: 2592000 # Max age in seconds (default 30 days) - max_context_items: 20 # Max observations injected into LLM context - -# === LLM BACKEND === -llm: - backend: openai # openai | anthropic | google - api_key: "" # API key (or use LLM_API_KEY env var) - base_url: https://api.openai.com/v1 # API base URL - model: gpt-4o-mini # Model name - timeout: 30 # Request timeout (seconds) - system_prompt: >- - You are a helpful assistant on a Meshtastic mesh network. - Keep responses very brief - 1-2 short sentences, under 300 characters. - Only give longer answers if the user explicitly asks for detail or explanation. - Be concise but friendly. No markdown formatting. - google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) - -# === WEATHER === -weather: - primary: openmeteo # openmeteo | wttr | llm - fallback: llm # openmeteo | wttr | llm | none - default_location: "" # Default location for !weather (optional) - -# === MESHMONITOR INTEGRATION === -meshmonitor: - enabled: false # Enable MeshMonitor trigger sync - url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) - inject_into_prompt: true # Include trigger list in LLM prompt - refresh_interval: 300 # Seconds between trigger refreshes - -# === KNOWLEDGE BASE (RAG) === -knowledge: - enabled: false # Enable knowledge base search - db_path: "" # Path to knowledge SQLite database - top_k: 5 # Number of chunks to retrieve per query - -# === MESH DATA SOURCES === -# Connect to Meshview and/or MeshMonitor instances for live mesh -# network analysis. Supports multiple sources. Configure via TUI -# with meshai --config (Mesh Sources menu). -# -# mesh_sources: -# - name: "my-meshview" -# type: meshview -# url: "https://meshview.example.com" -# refresh_interval: 300 -# enabled: true -# -# - name: "my-meshmonitor" -# type: meshmonitor -# url: "http://192.168.1.100:3333" -# api_token: "${MM_API_TOKEN}" -# refresh_interval: 300 -# enabled: true -mesh_sources: [] +# MeshAI Configuration +# LLM-powered Meshtastic assistant +# +# Copy this to config.yaml and customize as needed +# For Docker: mount as /data/config.yaml + +# === BOT IDENTITY === +bot: + name: ai # Bot's display name + owner: "" # Owner's callsign (optional) + respond_to_dms: true # Respond to direct messages + filter_bbs_protocols: true # Ignore advBBS sync/notification messages + +# === MESHTASTIC CONNECTION === +connection: + type: tcp # serial | tcp + serial_port: /dev/ttyUSB0 # For serial connection + tcp_host: localhost # For TCP connection (meshtasticd) + tcp_port: 4403 + +# === RESPONSE BEHAVIOR === +response: + delay_min: 2.2 # Min delay before responding (seconds) + delay_max: 3.0 # Max delay before responding + max_length: 200 # Max chars per message chunk + max_messages: 3 # Max message chunks per response + +# === CONVERSATION HISTORY === +history: + database: /data/conversations.db + max_messages_per_user: 50 # Messages to keep per user + conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) + auto_cleanup: true # Auto-delete old conversations + cleanup_interval_hours: 24 # How often to run cleanup + max_age_days: 30 # Delete conversations older than this + +# === MEMORY OPTIMIZATION === +memory: + enabled: true # Enable rolling summary memory + window_size: 4 # Recent message pairs to keep in full + summarize_threshold: 8 # Messages before re-summarizing + +# === MESH CONTEXT === +context: + enabled: true # Observe channel traffic for LLM context + observe_channels: [] # Channel indices to observe (empty = all) + ignore_nodes: [] # Node IDs to exclude from observation + max_age: 2592000 # Max age in seconds (default 30 days) + max_context_items: 20 # Max observations injected into LLM context + +# === LLM BACKEND === +llm: + backend: openai # openai | anthropic | google + api_key: "" # API key (or use LLM_API_KEY env var) + base_url: https://api.openai.com/v1 # API base URL + model: gpt-4o-mini # Model name + timeout: 30 # Request timeout (seconds) + system_prompt: >- + You are a helpful assistant on a Meshtastic mesh network. + Keep responses very brief - 1-2 short sentences, under 300 characters. + Only give longer answers if the user explicitly asks for detail or explanation. + Be concise but friendly. No markdown formatting. + google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) + +# === WEATHER === +weather: + primary: openmeteo # openmeteo | wttr | llm + fallback: llm # openmeteo | wttr | llm | none + default_location: "" # Default location for !weather (optional) + +# === MESHMONITOR INTEGRATION === +meshmonitor: + enabled: false # Enable MeshMonitor trigger sync + url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) + inject_into_prompt: true # Include trigger list in LLM prompt + refresh_interval: 300 # Seconds between trigger refreshes + +# === KNOWLEDGE BASE (RAG) === +knowledge: + enabled: false # Enable knowledge base search + db_path: "" # Path to knowledge SQLite database + top_k: 5 # Number of chunks to retrieve per query + +# === MESH DATA SOURCES === +# Connect to Meshview and/or MeshMonitor instances for live mesh +# network analysis. Supports multiple sources. Configure via TUI +# with meshai --config (Mesh Sources menu). +# +# mesh_sources: +# - name: "my-meshview" +# type: meshview +# url: "https://meshview.example.com" +# refresh_interval: 300 +# enabled: true +# +# - name: "my-meshmonitor" +# type: meshmonitor +# url: "http://192.168.1.100:3333" +# api_token: "${MM_API_TOKEN}" +# refresh_interval: 300 +# enabled: true +mesh_sources: [] # === MESH INTELLIGENCE === # Geographic clustering and health scoring for mesh analysis. @@ -123,3 +123,9 @@ mesh_intelligence: battery_warning_percent: 20 infra_overrides: [] region_labels: {} + +# === WEB DASHBOARD === +dashboard: + enabled: true + port: 8080 + host: "0.0.0.0" diff --git a/docker-compose.yml b/docker-compose.yml index d0151d9..5480741 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: ports: # Web-based config interface (ttyd) - "7682:7682" + # Dashboard API + - "8080:8080" volumes: # Persistent data (database, config) diff --git a/meshai/config.py b/meshai/config.py index 1e04f75..a498718 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -257,6 +257,16 @@ class MeshIntelligenceConfig: alert_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig) + + +@dataclass +class DashboardConfig: + """Web dashboard settings.""" + + enabled: bool = True + port: int = 8080 + host: str = "0.0.0.0" + @dataclass class Config: """Main configuration container.""" @@ -274,6 +284,7 @@ class Config: knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig) mesh_sources: list[MeshSourceConfig] = field(default_factory=list) mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig) + dashboard: DashboardConfig = field(default_factory=DashboardConfig) _config_path: Optional[Path] = field(default=None, repr=False) diff --git a/meshai/dashboard/__init__.py b/meshai/dashboard/__init__.py new file mode 100644 index 0000000..7b11a4f --- /dev/null +++ b/meshai/dashboard/__init__.py @@ -0,0 +1 @@ +"""Dashboard package for MeshAI web interface.""" diff --git a/meshai/dashboard/api/__init__.py b/meshai/dashboard/api/__init__.py new file mode 100644 index 0000000..6bff47b --- /dev/null +++ b/meshai/dashboard/api/__init__.py @@ -0,0 +1 @@ +"""Dashboard API routes package.""" diff --git a/meshai/dashboard/api/alert_routes.py b/meshai/dashboard/api/alert_routes.py new file mode 100644 index 0000000..af61352 --- /dev/null +++ b/meshai/dashboard/api/alert_routes.py @@ -0,0 +1,85 @@ +"""Alert API routes.""" + +from fastapi import APIRouter, Request + +router = APIRouter(tags=["alerts"]) + + +@router.get("/alerts/active") +async def get_active_alerts(request: Request): + """Get currently active alerts.""" + alert_engine = request.app.state.alert_engine + + if not alert_engine: + return [] + + # Get recent alerts from alert engine if it has internal state + alerts = [] + + # Check for AlertState or similar if available + if hasattr(alert_engine, "get_active_alerts"): + try: + raw_alerts = alert_engine.get_active_alerts() + for alert in raw_alerts: + alerts.append({ + "type": alert.get("type", "unknown"), + "severity": alert.get("severity", "info"), + "message": alert.get("message", ""), + "timestamp": alert.get("timestamp"), + "scope_type": alert.get("scope_type"), + "scope_value": alert.get("scope_value"), + }) + except Exception: + pass + elif hasattr(alert_engine, "_recent_alerts"): + try: + for alert in alert_engine._recent_alerts: + alerts.append({ + "type": alert.get("type", "unknown"), + "severity": alert.get("severity", "info"), + "message": alert.get("message", ""), + "timestamp": alert.get("timestamp"), + }) + except Exception: + pass + + return alerts + + +@router.get("/alerts/history") +async def get_alert_history( + request: Request, + limit: int = 50, + offset: int = 0, +): + """Get historical alerts with pagination.""" + # Historical alert data would come from SQLite + # For now, return empty list + return [] + + +@router.get("/subscriptions") +async def get_subscriptions(request: Request): + """Get all alert subscriptions.""" + subscription_manager = request.app.state.subscription_manager + + if not subscription_manager: + return [] + + try: + subs = subscription_manager.get_all_subs() + return [ + { + "id": sub["id"], + "user_id": sub["user_id"], + "sub_type": sub["sub_type"], + "schedule_time": sub.get("schedule_time"), + "schedule_day": sub.get("schedule_day"), + "scope_type": sub.get("scope_type", "mesh"), + "scope_value": sub.get("scope_value"), + "enabled": sub.get("enabled", 1) == 1, + } + for sub in subs + ] + except Exception: + return [] diff --git a/meshai/dashboard/api/config_routes.py b/meshai/dashboard/api/config_routes.py new file mode 100644 index 0000000..aed1877 --- /dev/null +++ b/meshai/dashboard/api/config_routes.py @@ -0,0 +1,183 @@ +"""Configuration API routes.""" + +import logging + +from fastapi import APIRouter, HTTPException, Request + +from meshai.config import ( + Config, + _dataclass_to_dict, + _dict_to_dataclass, + load_config, + save_config, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["config"]) + +# Sections that require restart when changed +RESTART_REQUIRED_SECTIONS = { + "connection", + "llm", + "mesh_sources", + "meshmonitor", + "dashboard", +} + +# Valid config section names +VALID_SECTIONS = { + "bot", + "connection", + "response", + "history", + "memory", + "context", + "commands", + "llm", + "weather", + "meshmonitor", + "knowledge", + "mesh_sources", + "mesh_intelligence", + "dashboard", +} + + +@router.get("/config") +async def get_full_config(request: Request): + """Get full configuration.""" + config = request.app.state.config + return _dataclass_to_dict(config) + + +@router.get("/config/{section}") +async def get_config_section(section: str, request: Request): + """Get a specific configuration section.""" + if section not in VALID_SECTIONS: + raise HTTPException( + status_code=404, + detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" + ) + + config = request.app.state.config + + if not hasattr(config, section): + raise HTTPException(status_code=404, detail=f"Section '{section}' not found") + + section_data = getattr(config, section) + + # Handle list types (mesh_sources) + if isinstance(section_data, list): + return [ + _dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item + for item in section_data + ] + + # Handle dataclass types + if hasattr(section_data, "__dataclass_fields__"): + return _dataclass_to_dict(section_data) + + return section_data + + +@router.put("/config/{section}") +async def update_config_section(section: str, request: Request): + """Update a configuration section.""" + if section not in VALID_SECTIONS: + raise HTTPException( + status_code=404, + detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" + ) + + config_path = request.app.state.config_path + if not config_path: + raise HTTPException(status_code=500, detail="Config path not set") + + try: + body = await request.json() + except Exception as e: + raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") + + try: + # Load fresh config from file to avoid conflicts + config = load_config(config_path) + + # Get the section's dataclass type + field_info = Config.__dataclass_fields__.get(section) + if not field_info: + raise HTTPException(status_code=404, detail=f"Section '{section}' not found") + + field_type = field_info.type + + # Handle list types (mesh_sources) + if section == "mesh_sources": + from meshai.config import MeshSourceConfig + new_value = [ + _dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item + for item in body + ] + # Handle dataclass types + elif hasattr(field_type, "__dataclass_fields__"): + new_value = _dict_to_dataclass(field_type, body) + else: + new_value = body + + # Set the section on config + setattr(config, section, new_value) + + # Save config to file + save_config(config, config_path) + + # Determine if restart is required + restart_required = section in RESTART_REQUIRED_SECTIONS + + # Update live config if restart not required + if not restart_required: + request.app.state.config = config + + logger.info(f"Config section '{section}' updated, restart_required={restart_required}") + + return {"saved": True, "restart_required": restart_required} + + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except Exception as e: + logger.error(f"Config update error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/config/test-llm") +async def test_llm_connection(request: Request): + """Test LLM backend connection.""" + config = request.app.state.config + + try: + # Create LLM backend based on config + api_key = config.resolve_api_key() + if not api_key: + return {"success": False, "error": "No API key configured"} + + backend_name = config.llm.backend.lower() + + if backend_name == "openai": + from meshai.backends import OpenAIBackend + backend = OpenAIBackend(config.llm, api_key, 0, 0) + elif backend_name == "anthropic": + from meshai.backends import AnthropicBackend + backend = AnthropicBackend(config.llm, api_key, 0, 0) + elif backend_name == "google": + from meshai.backends import GoogleBackend + backend = GoogleBackend(config.llm, api_key, 0, 0) + else: + return {"success": False, "error": f"Unknown backend: {backend_name}"} + + # Send test prompt + response = await backend.generate("Reply with 'OK' if you can read this.", []) + await backend.close() + + return {"success": True, "response": response} + + except Exception as e: + logger.error(f"LLM test error: {e}") + return {"success": False, "error": str(e)} diff --git a/meshai/dashboard/api/env_routes.py b/meshai/dashboard/api/env_routes.py new file mode 100644 index 0000000..acf9c6a --- /dev/null +++ b/meshai/dashboard/api/env_routes.py @@ -0,0 +1,44 @@ +"""Environmental data API routes (Phase 1 placeholder).""" + +from fastapi import APIRouter, Request + +router = APIRouter(tags=["environment"]) + + +@router.get("/env/status") +async def get_env_status(request: Request): + """Get environmental feeds status.""" + env_store = request.app.state.env_store + + if not env_store: + return {"enabled": False, "feeds": []} + + # Will be populated in Phase 1 when env_store exists + return { + "enabled": True, + "feeds": [], + } + + +@router.get("/env/active") +async def get_active_env(request: Request): + """Get active environmental conditions.""" + env_store = request.app.state.env_store + + if not env_store: + return [] + + # Will be populated in Phase 1 + return [] + + +@router.get("/env/swpc") +async def get_swpc_data(request: Request): + """Get SWPC space weather data.""" + env_store = request.app.state.env_store + + if not env_store: + return {"enabled": False} + + # Will be populated in Phase 1 + return {"enabled": False} diff --git a/meshai/dashboard/api/mesh_routes.py b/meshai/dashboard/api/mesh_routes.py new file mode 100644 index 0000000..c95739e --- /dev/null +++ b/meshai/dashboard/api/mesh_routes.py @@ -0,0 +1,356 @@ +"""Mesh health and node API routes.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request + +router = APIRouter(tags=["mesh"]) + + +def _serialize_health_score(score) -> dict: + """Serialize a HealthScore object.""" + return { + "composite": round(score.composite, 1), + "tier": score.tier, + "infrastructure": round(score.infrastructure, 1), + "utilization": round(score.utilization, 1), + "behavior": round(score.behavior, 1), + "power": round(score.power, 1), + "infra_online": score.infra_online, + "infra_total": score.infra_total, + "util_percent": round(score.util_percent, 1), + "flagged_nodes": score.flagged_nodes, + "battery_warnings": score.battery_warnings, + "solar_index": round(score.solar_index, 1), + } + + +def _serialize_region(region) -> dict: + """Serialize a RegionHealth object.""" + return { + "name": region.name, + "center_lat": region.center_lat, + "center_lon": region.center_lon, + "node_count": len(region.node_ids), + "locality_count": len(region.localities), + "score": _serialize_health_score(region.score), + "node_ids": region.node_ids, + } + + +def _format_timestamp(ts: Optional[float]) -> Optional[str]: + """Format a Unix timestamp as ISO string.""" + if not ts or ts <= 0: + return None + try: + return datetime.fromtimestamp(ts).isoformat() + except (ValueError, OSError): + return None + + +@router.get("/health") +async def get_health(request: Request): + """Get mesh health data.""" + health_engine = request.app.state.health_engine + + if not health_engine or not health_engine.mesh_health: + return { + "score": 0, + "tier": "Unknown", + "message": "Health engine not ready", + } + + health = health_engine.mesh_health + score = health.score + + return { + "score": round(score.composite, 1), + "tier": score.tier, + "pillars": { + "infrastructure": round(score.infrastructure, 1), + "utilization": round(score.utilization, 1), + "behavior": round(score.behavior, 1), + "power": round(score.power, 1), + }, + "infra_online": score.infra_online, + "infra_total": score.infra_total, + "util_percent": round(score.util_percent, 1), + "flagged_nodes": score.flagged_nodes, + "battery_warnings": score.battery_warnings, + "total_nodes": health.total_nodes, + "total_regions": health.total_regions, + "unlocated_count": len(health.unlocated_nodes), + "last_computed": _format_timestamp(health.last_computed), + "recommendations": [], # TODO: Add recommendations + } + + +@router.get("/nodes") +async def get_nodes(request: Request): + """Get all nodes.""" + data_store = request.app.state.data_store + health_engine = request.app.state.health_engine + + if not data_store: + return [] + + try: + raw_nodes = data_store.get_all_nodes() + except Exception: + return [] + + nodes = [] + for node in raw_nodes: + # Extract node_num from various formats + node_num = node.get("nodeNum") or node.get("num") or node.get("node_num") + if node_num is None: + node_id = node.get("node_id") or node.get("id") + if node_id and isinstance(node_id, str): + try: + node_num = int(node_id.lstrip("!"), 16) + except ValueError: + continue + + if node_num is None: + continue + + # Get health data if available + health_data = {} + if health_engine and health_engine.mesh_health: + node_health = health_engine.mesh_health.nodes.get(str(node_num)) + if node_health: + health_data = { + "region": node_health.region, + "locality": node_health.locality, + "is_infrastructure": node_health.is_infrastructure, + "is_online": node_health.is_online, + "packet_count_24h": node_health.packet_count_24h, + } + + # Build node dict + node_dict = { + "node_num": node_num, + "node_id_hex": f"!{node_num:08x}", + "short_name": node.get("shortName") or node.get("short_name") or "", + "long_name": node.get("longName") or node.get("long_name") or "", + "role": node.get("role") or "", + "latitude": node.get("latitude"), + "longitude": node.get("longitude"), + "last_heard": _format_timestamp(node.get("last_heard")), + "battery_level": node.get("battery_level") or node.get("batteryLevel"), + "voltage": node.get("voltage"), + "snr": node.get("snr"), + "firmware": node.get("firmware_version") or node.get("firmwareVersion") or "", + "hardware": node.get("hw_model") or node.get("hwModel") or "", + "uptime": node.get("uptime_seconds") or node.get("uptimeSeconds"), + "sources": node.get("_sources", []), + **health_data, + } + nodes.append(node_dict) + + return nodes + + +@router.get("/nodes/{node_num}") +async def get_node_detail(node_num: int, request: Request): + """Get detailed info for a specific node.""" + data_store = request.app.state.data_store + health_engine = request.app.state.health_engine + + if not data_store: + raise HTTPException(status_code=404, detail="Data store not available") + + # Find the node + try: + raw_nodes = data_store.get_all_nodes() + except Exception: + raise HTTPException(status_code=500, detail="Failed to fetch nodes") + + target_node = None + for node in raw_nodes: + n_num = node.get("nodeNum") or node.get("num") or node.get("node_num") + if n_num is None: + node_id = node.get("node_id") or node.get("id") + if node_id and isinstance(node_id, str): + try: + n_num = int(node_id.lstrip("!"), 16) + except ValueError: + continue + + if n_num == node_num: + target_node = node + break + + if not target_node: + raise HTTPException(status_code=404, detail=f"Node {node_num} not found") + + # Get health data + health_data = {} + if health_engine and health_engine.mesh_health: + node_health = health_engine.mesh_health.nodes.get(str(node_num)) + if node_health: + health_data = { + "region": node_health.region, + "locality": node_health.locality, + "is_infrastructure": node_health.is_infrastructure, + "is_online": node_health.is_online, + "packet_count_24h": node_health.packet_count_24h, + "text_packet_count_24h": node_health.text_packet_count_24h, + "non_text_packets": node_health.non_text_packets, + "has_solar": node_health.has_solar, + } + + # Get neighbors from edges + neighbors = [] + try: + edges = data_store.get_all_edges() + for edge in edges: + from_num = edge.get("from_node") or edge.get("from") + to_num = edge.get("to_node") or edge.get("to") + + if from_num == node_num: + neighbors.append({ + "node_num": to_num, + "snr": edge.get("snr"), + }) + elif to_num == node_num: + neighbors.append({ + "node_num": from_num, + "snr": edge.get("snr"), + }) + except Exception: + pass + + return { + "node_num": node_num, + "node_id_hex": f"!{node_num:08x}", + "short_name": target_node.get("shortName") or target_node.get("short_name") or "", + "long_name": target_node.get("longName") or target_node.get("long_name") or "", + "role": target_node.get("role") or "", + "latitude": target_node.get("latitude"), + "longitude": target_node.get("longitude"), + "last_heard": _format_timestamp(target_node.get("last_heard")), + "battery_level": target_node.get("battery_level") or target_node.get("batteryLevel"), + "voltage": target_node.get("voltage"), + "snr": target_node.get("snr"), + "firmware": target_node.get("firmware_version") or target_node.get("firmwareVersion") or "", + "hardware": target_node.get("hw_model") or target_node.get("hwModel") or "", + "uptime": target_node.get("uptime_seconds") or target_node.get("uptimeSeconds"), + "sources": target_node.get("_sources", []), + "neighbors": neighbors, + **health_data, + } + + +@router.get("/regions") +async def get_regions(request: Request): + """Get region summaries.""" + health_engine = request.app.state.health_engine + + if not health_engine or not health_engine.mesh_health: + return [] + + regions = [] + for region in health_engine.mesh_health.regions: + # Count online infrastructure + infra_online = 0 + infra_total = 0 + online_count = 0 + + for nid in region.node_ids: + node = health_engine.mesh_health.nodes.get(nid) + if node: + if node.is_online: + online_count += 1 + if node.is_infrastructure: + infra_total += 1 + if node.is_online: + infra_online += 1 + + regions.append({ + "name": region.name, + "local_name": region.name, # Could be overridden by region_labels + "node_count": len(region.node_ids), + "infra_count": infra_total, + "infra_online": infra_online, + "online_count": online_count, + "score": round(region.score.composite, 1), + "tier": region.score.tier, + "center_lat": region.center_lat, + "center_lon": region.center_lon, + }) + + return regions + + +@router.get("/sources") +async def get_sources(request: Request): + """Get per-source health information.""" + data_store = request.app.state.data_store + + if not data_store: + return [] + + sources = [] + try: + for name, source in data_store._sources.items(): + source_info = { + "name": name, + "type": "meshview" if hasattr(source, "edges") else "meshmonitor", + "url": getattr(source, "url", ""), + "is_loaded": source.is_loaded, + "last_error": source.last_error, + "consecutive_errors": getattr(source, "consecutive_errors", 0), + "response_time_ms": getattr(source, "last_response_time_ms", None), + "tick_count": getattr(source, "tick_count", 0), + "node_count": len(source.nodes) if hasattr(source, "nodes") else 0, + } + sources.append(source_info) + except Exception: + pass + + return sources + + +@router.get("/edges") +async def get_edges(request: Request): + """Get neighbor/edge relationships.""" + data_store = request.app.state.data_store + + if not data_store: + return [] + + try: + raw_edges = data_store.get_all_edges() + except Exception: + return [] + + edges = [] + for edge in raw_edges: + from_num = edge.get("from_node") or edge.get("from") + to_num = edge.get("to_node") or edge.get("to") + snr = edge.get("snr") + + # Derive quality from SNR + if snr is None: + quality = "unknown" + elif snr > 12: + quality = "excellent" + elif snr > 8: + quality = "good" + elif snr > 5: + quality = "fair" + elif snr > 3: + quality = "marginal" + else: + quality = "poor" + + edges.append({ + "from_node": from_num, + "to_node": to_num, + "snr": snr, + "quality": quality, + }) + + return edges diff --git a/meshai/dashboard/api/system_routes.py b/meshai/dashboard/api/system_routes.py new file mode 100644 index 0000000..36e5152 --- /dev/null +++ b/meshai/dashboard/api/system_routes.py @@ -0,0 +1,63 @@ +"""System status and control API routes.""" + +import time +from pathlib import Path + +from fastapi import APIRouter, Request + +from meshai import __version__ +from meshai.commands.status import _start_time + +router = APIRouter(tags=["system"]) + + +@router.get("/status") +async def get_status(request: Request): + """Get system status information.""" + config = request.app.state.config + data_store = request.app.state.data_store + + # Calculate uptime + uptime_seconds = time.time() - _start_time if _start_time else 0 + + # Connection info + conn = config.connection + if conn.type == "tcp": + connection_target = f"{conn.tcp_host}:{conn.tcp_port}" + else: + connection_target = conn.serial_port + + # Count nodes and sources + node_count = 0 + source_count = 0 + connected = False + + if data_store: + try: + nodes = data_store.get_all_nodes() + node_count = len(nodes) if nodes else 0 + source_count = data_store.source_count + connected = any(s.is_loaded for s in data_store._sources.values()) + except Exception: + pass + + return { + "version": __version__, + "uptime_seconds": round(uptime_seconds, 1), + "bot_name": config.bot.name, + "connection_type": conn.type, + "connection_target": connection_target, + "connected": connected, + "node_count": node_count, + "source_count": source_count, + "env_feeds_enabled": request.app.state.env_store is not None, + "dashboard_port": config.dashboard.port, + } + + +@router.post("/restart") +async def restart_bot(): + """Signal the bot to restart.""" + restart_file = Path("/tmp/meshai_restart") + restart_file.touch() + return {"restarting": True} diff --git a/meshai/dashboard/server.py b/meshai/dashboard/server.py new file mode 100644 index 0000000..4217139 --- /dev/null +++ b/meshai/dashboard/server.py @@ -0,0 +1,108 @@ +"""FastAPI server for MeshAI dashboard.""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from .ws import DashboardBroadcaster, router as ws_router + +if TYPE_CHECKING: + from ..main import MeshAI + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI lifespan context manager.""" + logger.info("Dashboard starting up") + yield + logger.info("Dashboard shutting down") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title="MeshAI Dashboard", + description="Web dashboard for MeshAI mesh network monitoring", + version="0.1.0", + lifespan=lifespan, + ) + + # CORS middleware for Vite dev server + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:8080"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Import and include API routers + from .api.system_routes import router as system_router + from .api.config_routes import router as config_router + from .api.mesh_routes import router as mesh_router + from .api.env_routes import router as env_router + from .api.alert_routes import router as alert_router + + app.include_router(system_router, prefix="/api") + app.include_router(config_router, prefix="/api") + app.include_router(mesh_router, prefix="/api") + app.include_router(env_router, prefix="/api") + app.include_router(alert_router, prefix="/api") + + # WebSocket router (no prefix, path is /ws/live) + app.include_router(ws_router) + + # Static files - mount LAST so /api routes take priority + static_dir = Path(__file__).parent / "static" + if static_dir.exists(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + + return app + + +async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster: + """Start the dashboard server in the MeshAI asyncio loop. + + Args: + meshai_instance: The running MeshAI instance + + Returns: + DashboardBroadcaster instance for pushing updates + """ + app = create_app() + + # Populate app.state with MeshAI internals + app.state.config = meshai_instance.config + app.state.config_path = meshai_instance.config._config_path + app.state.data_store = meshai_instance.data_store + app.state.health_engine = meshai_instance.health_engine + app.state.alert_engine = getattr(meshai_instance, "alert_engine", None) + app.state.env_store = getattr(meshai_instance, "env_store", None) + app.state.subscription_manager = meshai_instance.subscription_manager + + # Create broadcaster and attach to app state + broadcaster = DashboardBroadcaster() + app.state.broadcaster = broadcaster + + # Configure uvicorn + config = uvicorn.Config( + app, + host=meshai_instance.config.dashboard.host, + port=meshai_instance.config.dashboard.port, + log_level="warning", # Don't spam meshai logs with access logs + ) + server = uvicorn.Server(config) + + # Start server as asyncio task (runs in same event loop as MeshAI) + asyncio.create_task(server.serve()) + + return broadcaster diff --git a/meshai/dashboard/static/index.html b/meshai/dashboard/static/index.html new file mode 100644 index 0000000..35a3925 --- /dev/null +++ b/meshai/dashboard/static/index.html @@ -0,0 +1,20 @@ + + +
+Frontend build pending - API is live.
+ +