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:
K7ZVX 2026-05-14 22:43:06 +00:00
commit d6bc6b2b89
46 changed files with 11450 additions and 11450 deletions

View file

@ -1 +1 @@
"""Dashboard package for MeshAI web interface."""
"""Dashboard package for MeshAI web interface."""

View file

@ -1 +1 @@
"""Dashboard API routes package."""
"""Dashboard API routes package."""

View file

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

View file

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

View file

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

View file

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

View file

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