mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 17:34:44 +02:00
build: normalize all line endings to LF
One-time renormalization pass under the .gitattributes added in the previous commit. Every tracked text file now uses LF. No semantic changes — verified via git diff --cached --ignore-all-space showing zero real differences. Future diffs will only show real content changes. This commit will appear huge in git log --stat but represents zero behavior change. Use git log --follow --ignore-all-space or git blame -w when archaeologically tracing through this commit.
This commit is contained in:
parent
211c642b60
commit
d6bc6b2b89
46 changed files with 11450 additions and 11450 deletions
|
|
@ -1 +1 @@
|
|||
"""Dashboard package for MeshAI web interface."""
|
||||
"""Dashboard package for MeshAI web interface."""
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
"""Dashboard API routes package."""
|
||||
"""Dashboard API routes package."""
|
||||
|
|
|
|||
|
|
@ -1,185 +1,185 @@
|
|||
"""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
|
||||
"""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 = {
|
||||
"notifications",
|
||||
"environmental",
|
||||
"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)}
|
||||
"environmental",
|
||||
"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)}
|
||||
|
|
|
|||
|
|
@ -1,305 +1,305 @@
|
|||
"""Notification API routes with comprehensive testing."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class TestRequest(BaseModel):
|
||||
"""Request body for test endpoint."""
|
||||
send: bool = False # Legacy: True = send_test
|
||||
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
||||
|
||||
|
||||
class ChannelTestRequest(BaseModel):
|
||||
"""Request body for channel connectivity test."""
|
||||
type: str # mesh_broadcast, mesh_dm, email, webhook
|
||||
# Mesh broadcast
|
||||
channel_index: Optional[int] = 0
|
||||
# Mesh DM
|
||||
node_ids: Optional[List[str]] = []
|
||||
# Email
|
||||
smtp_host: Optional[str] = ""
|
||||
smtp_port: Optional[int] = 587
|
||||
smtp_user: Optional[str] = ""
|
||||
smtp_password: Optional[str] = ""
|
||||
smtp_tls: Optional[bool] = True
|
||||
from_address: Optional[str] = ""
|
||||
recipients: Optional[List[str]] = []
|
||||
# Webhook
|
||||
url: Optional[str] = ""
|
||||
headers: Optional[Dict[str, str]] = {}
|
||||
|
||||
|
||||
class RuleSourcesRequest(BaseModel):
|
||||
"""Request body for rule sources health check."""
|
||||
categories: List[str] = []
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
try:
|
||||
from ...notifications.categories import list_categories
|
||||
return list_categories()
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules with stats."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
return []
|
||||
|
||||
rules = notification_router.get_rules()
|
||||
|
||||
# Enhance rules with stats
|
||||
result = []
|
||||
for i, rule in enumerate(rules):
|
||||
rule_copy = dict(rule)
|
||||
stats = rule_copy.pop("_stats", {})
|
||||
rule_copy["stats"] = stats
|
||||
rule_copy["index"] = i
|
||||
result.append(rule_copy)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rules/{rule_index}/stats")
|
||||
async def get_rule_stats(request: Request, rule_index: int):
|
||||
"""Get statistics for a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
rules_config = getattr(request.app.state, "config", None)
|
||||
if rules_config:
|
||||
rules_config = getattr(rules_config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
rule = rules_config[rule_index]
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||
return notification_router.get_rule_stats(rule_name)
|
||||
|
||||
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
||||
|
||||
|
||||
@router.post("/channels/test")
|
||||
async def test_channel(request: Request, body: ChannelTestRequest):
|
||||
"""Test channel connectivity without sending actual alert content.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str, # Human-readable result
|
||||
"error": str, # Detailed error if failed
|
||||
"details": {} # Channel-specific details
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
# Build channel config from request
|
||||
channel_config = {"type": body.type}
|
||||
|
||||
if body.type == "mesh_broadcast":
|
||||
channel_config["channel_index"] = body.channel_index or 0
|
||||
elif body.type == "mesh_dm":
|
||||
channel_config["node_ids"] = body.node_ids or []
|
||||
elif body.type == "email":
|
||||
channel_config.update({
|
||||
"smtp_host": body.smtp_host or "",
|
||||
"smtp_port": body.smtp_port or 587,
|
||||
"smtp_user": body.smtp_user or "",
|
||||
"smtp_password": body.smtp_password or "",
|
||||
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
||||
"from_address": body.from_address or "",
|
||||
"recipients": body.recipients or [],
|
||||
})
|
||||
elif body.type == "webhook":
|
||||
channel_config.update({
|
||||
"url": body.url or "",
|
||||
"headers": body.headers or {},
|
||||
})
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unknown channel type",
|
||||
"error": f"Channel type '{body.type}' is not supported",
|
||||
"details": {}
|
||||
}
|
||||
|
||||
result = await notification_router.test_channel(channel_config)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
||||
"""Test a notification rule against current conditions.
|
||||
|
||||
Returns comprehensive test result including:
|
||||
- Live data from relevant environmental feeds
|
||||
- Matching alerts (conditions that would fire)
|
||||
- Near-misses (filtered by severity threshold)
|
||||
- Preview messages and delivery status
|
||||
- Source health (which feeds are enabled)
|
||||
- Rule statistics (last fired, fire count)
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
action = body.action if body else "preview"
|
||||
send = body.send if body else False
|
||||
|
||||
# Legacy support
|
||||
if send and action == "preview":
|
||||
action = "send_test"
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/preview")
|
||||
async def preview_rule(request: Request, rule_index: int):
|
||||
"""Preview what a rule would match right now (without sending)."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="preview",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/sources")
|
||||
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
||||
"""Get data source health for a set of categories.
|
||||
|
||||
Returns per-category source status:
|
||||
{
|
||||
"category_id": {
|
||||
"enabled": true/false,
|
||||
"active_events": number,
|
||||
"source": "nws"/"swpc"/etc,
|
||||
"status": "ok"/"disabled"/"no_data"
|
||||
}
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
return notification_router.get_source_health(body.categories, env_store)
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-status")
|
||||
async def send_rule_status(request: Request, rule_index: int):
|
||||
"""Send current conditions summary through a rule's channel.
|
||||
|
||||
Formats current live data as a readable message and delivers
|
||||
through the rule's configured channel with [STATUS] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_status",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-test")
|
||||
async def send_rule_test(request: Request, rule_index: int):
|
||||
"""Send example alert message through a rule's channel.
|
||||
|
||||
Sends the example_message from the rule's first category
|
||||
through the configured channel with [TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_test",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-live")
|
||||
async def send_rule_live(request: Request, rule_index: int):
|
||||
"""Send actual live alert through a rule's channel.
|
||||
|
||||
Only available when there are matching conditions.
|
||||
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_live",
|
||||
)
|
||||
|
||||
return result
|
||||
"""Notification API routes with comprehensive testing."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class TestRequest(BaseModel):
|
||||
"""Request body for test endpoint."""
|
||||
send: bool = False # Legacy: True = send_test
|
||||
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
||||
|
||||
|
||||
class ChannelTestRequest(BaseModel):
|
||||
"""Request body for channel connectivity test."""
|
||||
type: str # mesh_broadcast, mesh_dm, email, webhook
|
||||
# Mesh broadcast
|
||||
channel_index: Optional[int] = 0
|
||||
# Mesh DM
|
||||
node_ids: Optional[List[str]] = []
|
||||
# Email
|
||||
smtp_host: Optional[str] = ""
|
||||
smtp_port: Optional[int] = 587
|
||||
smtp_user: Optional[str] = ""
|
||||
smtp_password: Optional[str] = ""
|
||||
smtp_tls: Optional[bool] = True
|
||||
from_address: Optional[str] = ""
|
||||
recipients: Optional[List[str]] = []
|
||||
# Webhook
|
||||
url: Optional[str] = ""
|
||||
headers: Optional[Dict[str, str]] = {}
|
||||
|
||||
|
||||
class RuleSourcesRequest(BaseModel):
|
||||
"""Request body for rule sources health check."""
|
||||
categories: List[str] = []
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
try:
|
||||
from ...notifications.categories import list_categories
|
||||
return list_categories()
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules with stats."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
return []
|
||||
|
||||
rules = notification_router.get_rules()
|
||||
|
||||
# Enhance rules with stats
|
||||
result = []
|
||||
for i, rule in enumerate(rules):
|
||||
rule_copy = dict(rule)
|
||||
stats = rule_copy.pop("_stats", {})
|
||||
rule_copy["stats"] = stats
|
||||
rule_copy["index"] = i
|
||||
result.append(rule_copy)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rules/{rule_index}/stats")
|
||||
async def get_rule_stats(request: Request, rule_index: int):
|
||||
"""Get statistics for a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
rules_config = getattr(request.app.state, "config", None)
|
||||
if rules_config:
|
||||
rules_config = getattr(rules_config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
rule = rules_config[rule_index]
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||
return notification_router.get_rule_stats(rule_name)
|
||||
|
||||
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
||||
|
||||
|
||||
@router.post("/channels/test")
|
||||
async def test_channel(request: Request, body: ChannelTestRequest):
|
||||
"""Test channel connectivity without sending actual alert content.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str, # Human-readable result
|
||||
"error": str, # Detailed error if failed
|
||||
"details": {} # Channel-specific details
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
# Build channel config from request
|
||||
channel_config = {"type": body.type}
|
||||
|
||||
if body.type == "mesh_broadcast":
|
||||
channel_config["channel_index"] = body.channel_index or 0
|
||||
elif body.type == "mesh_dm":
|
||||
channel_config["node_ids"] = body.node_ids or []
|
||||
elif body.type == "email":
|
||||
channel_config.update({
|
||||
"smtp_host": body.smtp_host or "",
|
||||
"smtp_port": body.smtp_port or 587,
|
||||
"smtp_user": body.smtp_user or "",
|
||||
"smtp_password": body.smtp_password or "",
|
||||
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
||||
"from_address": body.from_address or "",
|
||||
"recipients": body.recipients or [],
|
||||
})
|
||||
elif body.type == "webhook":
|
||||
channel_config.update({
|
||||
"url": body.url or "",
|
||||
"headers": body.headers or {},
|
||||
})
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unknown channel type",
|
||||
"error": f"Channel type '{body.type}' is not supported",
|
||||
"details": {}
|
||||
}
|
||||
|
||||
result = await notification_router.test_channel(channel_config)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
||||
"""Test a notification rule against current conditions.
|
||||
|
||||
Returns comprehensive test result including:
|
||||
- Live data from relevant environmental feeds
|
||||
- Matching alerts (conditions that would fire)
|
||||
- Near-misses (filtered by severity threshold)
|
||||
- Preview messages and delivery status
|
||||
- Source health (which feeds are enabled)
|
||||
- Rule statistics (last fired, fire count)
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
action = body.action if body else "preview"
|
||||
send = body.send if body else False
|
||||
|
||||
# Legacy support
|
||||
if send and action == "preview":
|
||||
action = "send_test"
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/preview")
|
||||
async def preview_rule(request: Request, rule_index: int):
|
||||
"""Preview what a rule would match right now (without sending)."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="preview",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/sources")
|
||||
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
||||
"""Get data source health for a set of categories.
|
||||
|
||||
Returns per-category source status:
|
||||
{
|
||||
"category_id": {
|
||||
"enabled": true/false,
|
||||
"active_events": number,
|
||||
"source": "nws"/"swpc"/etc,
|
||||
"status": "ok"/"disabled"/"no_data"
|
||||
}
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
return notification_router.get_source_health(body.categories, env_store)
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-status")
|
||||
async def send_rule_status(request: Request, rule_index: int):
|
||||
"""Send current conditions summary through a rule's channel.
|
||||
|
||||
Formats current live data as a readable message and delivers
|
||||
through the rule's configured channel with [STATUS] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_status",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-test")
|
||||
async def send_rule_test(request: Request, rule_index: int):
|
||||
"""Send example alert message through a rule's channel.
|
||||
|
||||
Sends the example_message from the rule's first category
|
||||
through the configured channel with [TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_test",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-live")
|
||||
async def send_rule_live(request: Request, rule_index: int):
|
||||
"""Send actual live alert through a rule's channel.
|
||||
|
||||
Only available when there are matching conditions.
|
||||
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_live",
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,63 +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}
|
||||
"""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}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,134 @@
|
|||
"""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, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
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
|
||||
"""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, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
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
|
||||
from .api.notification_routes import router as notification_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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
app.include_router(notification_router, prefix="/api")
|
||||
# WebSocket router (no prefix, path is /ws/live)
|
||||
app.include_router(ws_router)
|
||||
|
||||
# Static files setup for SPA
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
index_html = static_dir / "index.html"
|
||||
|
||||
if static_dir.exists():
|
||||
# Mount /assets for JS, CSS, images
|
||||
assets_dir = static_dir / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
||||
|
||||
# SPA catch-all: serve index.html for any non-API, non-static path
|
||||
# This enables React Router client-side routing
|
||||
@app.get("/{path:path}")
|
||||
async def spa_catch_all(request: Request, path: str):
|
||||
# Let static files be served directly if they exist
|
||||
file_path = static_dir / path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
# Otherwise serve index.html for client-side routing
|
||||
return FileResponse(index_html)
|
||||
|
||||
# Explicit root route
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse(index_html)
|
||||
|
||||
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
|
||||
# WebSocket router (no prefix, path is /ws/live)
|
||||
app.include_router(ws_router)
|
||||
|
||||
# Static files setup for SPA
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
index_html = static_dir / "index.html"
|
||||
|
||||
if static_dir.exists():
|
||||
# Mount /assets for JS, CSS, images
|
||||
assets_dir = static_dir / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
||||
|
||||
# SPA catch-all: serve index.html for any non-API, non-static path
|
||||
# This enables React Router client-side routing
|
||||
@app.get("/{path:path}")
|
||||
async def spa_catch_all(request: Request, path: str):
|
||||
# Let static files be served directly if they exist
|
||||
file_path = static_dir / path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
# Otherwise serve index.html for client-side routing
|
||||
return FileResponse(index_html)
|
||||
|
||||
# Explicit root route
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse(index_html)
|
||||
|
||||
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
|
||||
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
||||
app.state.connector = meshai_instance.connector
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,115 +1,115 @@
|
|||
"""WebSocket support for real-time dashboard updates."""
|
||||
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DashboardBroadcaster:
|
||||
"""Manages active WebSocket connections for real-time updates."""
|
||||
|
||||
def __init__(self):
|
||||
self._connections: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept and register a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
self._connections.add(websocket)
|
||||
logger.debug(f"WebSocket connected, total: {len(self._connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
self._connections.discard(websocket)
|
||||
logger.debug(f"WebSocket disconnected, total: {len(self._connections)}")
|
||||
|
||||
async def broadcast(self, msg_type: str, data: dict) -> None:
|
||||
"""Broadcast a message to all connected clients.
|
||||
|
||||
Args:
|
||||
msg_type: Message type (e.g., "health_update", "alert_fired")
|
||||
data: Message payload
|
||||
"""
|
||||
if not self._connections:
|
||||
return
|
||||
|
||||
message = {"type": msg_type, "data": data}
|
||||
dead_connections = set()
|
||||
|
||||
for websocket in self._connections:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed: {e}")
|
||||
dead_connections.add(websocket)
|
||||
|
||||
# Remove dead connections
|
||||
for ws in dead_connections:
|
||||
self._connections.discard(ws)
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
"""Get number of active connections."""
|
||||
return len(self._connections)
|
||||
|
||||
|
||||
def _serialize_health(mesh_health) -> dict:
|
||||
"""Serialize MeshHealth for WebSocket transmission."""
|
||||
if not mesh_health:
|
||||
return {"score": 0, "tier": "Unknown", "message": "No data"}
|
||||
|
||||
score = mesh_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": mesh_health.total_nodes,
|
||||
"total_regions": mesh_health.total_regions,
|
||||
"last_computed": mesh_health.last_computed,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/live")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for real-time updates."""
|
||||
# Get broadcaster from app state
|
||||
app_state = websocket.app.state
|
||||
broadcaster = getattr(app_state, "broadcaster", None)
|
||||
|
||||
if not broadcaster:
|
||||
await websocket.close(code=1011, reason="Broadcaster not initialized")
|
||||
return
|
||||
|
||||
await broadcaster.connect(websocket)
|
||||
|
||||
try:
|
||||
# Send initial state snapshot on connect
|
||||
health_engine = getattr(app_state, "health_engine", None)
|
||||
if health_engine and health_engine.mesh_health:
|
||||
await websocket.send_json({
|
||||
"type": "health_update",
|
||||
"data": _serialize_health(health_engine.mesh_health)
|
||||
})
|
||||
|
||||
# Keep connection alive, receive client keepalive pings
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
broadcaster.disconnect(websocket)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket error: {e}")
|
||||
broadcaster.disconnect(websocket)
|
||||
"""WebSocket support for real-time dashboard updates."""
|
||||
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DashboardBroadcaster:
|
||||
"""Manages active WebSocket connections for real-time updates."""
|
||||
|
||||
def __init__(self):
|
||||
self._connections: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept and register a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
self._connections.add(websocket)
|
||||
logger.debug(f"WebSocket connected, total: {len(self._connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
self._connections.discard(websocket)
|
||||
logger.debug(f"WebSocket disconnected, total: {len(self._connections)}")
|
||||
|
||||
async def broadcast(self, msg_type: str, data: dict) -> None:
|
||||
"""Broadcast a message to all connected clients.
|
||||
|
||||
Args:
|
||||
msg_type: Message type (e.g., "health_update", "alert_fired")
|
||||
data: Message payload
|
||||
"""
|
||||
if not self._connections:
|
||||
return
|
||||
|
||||
message = {"type": msg_type, "data": data}
|
||||
dead_connections = set()
|
||||
|
||||
for websocket in self._connections:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed: {e}")
|
||||
dead_connections.add(websocket)
|
||||
|
||||
# Remove dead connections
|
||||
for ws in dead_connections:
|
||||
self._connections.discard(ws)
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
"""Get number of active connections."""
|
||||
return len(self._connections)
|
||||
|
||||
|
||||
def _serialize_health(mesh_health) -> dict:
|
||||
"""Serialize MeshHealth for WebSocket transmission."""
|
||||
if not mesh_health:
|
||||
return {"score": 0, "tier": "Unknown", "message": "No data"}
|
||||
|
||||
score = mesh_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": mesh_health.total_nodes,
|
||||
"total_regions": mesh_health.total_regions,
|
||||
"last_computed": mesh_health.last_computed,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/live")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for real-time updates."""
|
||||
# Get broadcaster from app state
|
||||
app_state = websocket.app.state
|
||||
broadcaster = getattr(app_state, "broadcaster", None)
|
||||
|
||||
if not broadcaster:
|
||||
await websocket.close(code=1011, reason="Broadcaster not initialized")
|
||||
return
|
||||
|
||||
await broadcaster.connect(websocket)
|
||||
|
||||
try:
|
||||
# Send initial state snapshot on connect
|
||||
health_engine = getattr(app_state, "health_engine", None)
|
||||
if health_engine and health_engine.mesh_health:
|
||||
await websocket.send_json({
|
||||
"type": "health_update",
|
||||
"data": _serialize_health(health_engine.mesh_health)
|
||||
})
|
||||
|
||||
# Keep connection alive, receive client keepalive pings
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
broadcaster.disconnect(websocket)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket error: {e}")
|
||||
broadcaster.disconnect(websocket)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue