mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat: Phase 1 — multi-source data aggregation from Meshview and MeshMonitor APIs
- Add MeshviewSource class for fetching nodes, edges, stats from Meshview API - Add MeshMonitorDataSource class for fetching nodes, channels, telemetry, traceroutes, network stats, topology, packets, solar from MeshMonitor API - Add MeshSourceManager for managing multiple sources with aggregation - Add MeshSourceConfig dataclass and mesh_sources list to config - Integrate source_manager into main.py with periodic refresh - Add source_manager parameter to MessageRouter (for future Phase 3) - Add Mesh Sources TUI menu with add/edit/remove/test functionality - Update config.example.yaml with mesh_sources section Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
584d1b199d
commit
b945558ba3
9 changed files with 2830 additions and 1856 deletions
|
|
@ -71,7 +71,7 @@ weather:
|
|||
# === MESHMONITOR INTEGRATION ===
|
||||
meshmonitor:
|
||||
enabled: false # Enable MeshMonitor trigger sync
|
||||
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:8080)
|
||||
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
|
||||
inject_into_prompt: true # Include trigger list in LLM prompt
|
||||
refresh_interval: 300 # Seconds between trigger refreshes
|
||||
|
||||
|
|
@ -80,5 +80,23 @@ knowledge:
|
|||
enabled: false # Enable knowledge base search
|
||||
db_path: "" # Path to knowledge SQLite database
|
||||
top_k: 5 # Number of chunks to retrieve per query
|
||||
fts_weight: 0.5 # Weight for FTS5 keyword matches (0-1)
|
||||
vector_weight: 0.5 # Weight for vector semantic matches (0-1)
|
||||
|
||||
# === MESH DATA SOURCES ===
|
||||
# Connect to Meshview and/or MeshMonitor instances for live mesh
|
||||
# network analysis. Supports multiple sources. Configure via TUI
|
||||
# with meshai --config (Mesh Sources menu).
|
||||
#
|
||||
# mesh_sources:
|
||||
# - name: "my-meshview"
|
||||
# type: meshview
|
||||
# url: "https://meshview.example.com"
|
||||
# refresh_interval: 300
|
||||
# enabled: true
|
||||
#
|
||||
# - name: "my-meshmonitor"
|
||||
# type: meshmonitor
|
||||
# url: "http://192.168.1.100:3333"
|
||||
# api_token: "${MM_API_TOKEN}"
|
||||
# refresh_interval: 300
|
||||
# enabled: true
|
||||
mesh_sources: []
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Rich-based TUI configurator for MeshAI."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ from rich.prompt import Confirm, IntPrompt, Prompt
|
|||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..config import Config, load_config, save_config
|
||||
from ..config import Config, MeshSourceConfig, load_config, save_config
|
||||
|
||||
console = Console()
|
||||
|
||||
|
|
@ -84,7 +85,14 @@ class Configurator:
|
|||
kb_status = self._status_icon(self.config.knowledge.enabled)
|
||||
kb_path = self.config.knowledge.db_path or "[dim]not set[/dim]"
|
||||
table.add_row("10", "Knowledge Base", f"{kb_status} {kb_path}")
|
||||
table.add_row("11", "Setup Wizard", "[dim]First-time setup[/dim]")
|
||||
|
||||
# Mesh Sources
|
||||
total_sources = len(self.config.mesh_sources)
|
||||
enabled_sources = sum(1 for s in self.config.mesh_sources if s.enabled)
|
||||
src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]"
|
||||
table.add_row("11", "Mesh Sources", src_status)
|
||||
|
||||
table.add_row("12", "Setup Wizard", "[dim]First-time setup[/dim]")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
|
@ -93,13 +101,13 @@ class Configurator:
|
|||
if self.modified:
|
||||
console.print("[yellow]* Unsaved changes[/yellow]")
|
||||
console.print()
|
||||
console.print("[white]12. Save[/white] [dim]Save config, stay in menu[/dim]")
|
||||
console.print("[green]13. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
|
||||
console.print("[white]14. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
|
||||
console.print("[white]15. Exit without Saving[/white]")
|
||||
console.print("[white]13. Save[/white] [dim]Save config, stay in menu[/dim]")
|
||||
console.print("[green]14. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
|
||||
console.print("[white]15. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
|
||||
console.print("[white]16. Exit without Saving[/white]")
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask("Select option", default=13)
|
||||
choice = IntPrompt.ask("Select option", default=14)
|
||||
|
||||
if choice == 1:
|
||||
self._bot_settings()
|
||||
|
|
@ -122,15 +130,17 @@ class Configurator:
|
|||
elif choice == 10:
|
||||
self._knowledge_settings()
|
||||
elif choice == 11:
|
||||
self._setup_wizard()
|
||||
self._mesh_sources_settings()
|
||||
elif choice == 12:
|
||||
self._save_only()
|
||||
self._setup_wizard()
|
||||
elif choice == 13:
|
||||
self._save_and_restart()
|
||||
self._save_only()
|
||||
elif choice == 14:
|
||||
self._save_and_restart()
|
||||
elif choice == 15:
|
||||
self._save_restart_exit()
|
||||
break
|
||||
elif choice == 15:
|
||||
elif choice == 16:
|
||||
break
|
||||
|
||||
def _show_header(self) -> None:
|
||||
|
|
@ -728,6 +738,276 @@ class Configurator:
|
|||
self.config.knowledge.top_k = value
|
||||
self.modified = True
|
||||
|
||||
def _mesh_sources_settings(self) -> None:
|
||||
"""Mesh data sources settings submenu."""
|
||||
while True:
|
||||
self._clear()
|
||||
console.print("[bold]Mesh Data Sources[/bold]\n")
|
||||
console.print("[dim]Connect to Meshview and/or MeshMonitor instances for live mesh data.[/dim]\n")
|
||||
|
||||
# Display configured sources
|
||||
if self.config.mesh_sources:
|
||||
table = Table(box=box.ROUNDED)
|
||||
table.add_column("#", style="cyan", width=3)
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Type", style="blue")
|
||||
table.add_column("URL", style="dim")
|
||||
table.add_column("Enabled", style="green")
|
||||
|
||||
for i, src in enumerate(self.config.mesh_sources, 1):
|
||||
table.add_row(
|
||||
str(i),
|
||||
src.name,
|
||||
src.type,
|
||||
src.url[:40] + "..." if len(src.url) > 40 else src.url,
|
||||
self._status_icon(src.enabled),
|
||||
)
|
||||
console.print(table)
|
||||
else:
|
||||
console.print("[dim]No sources configured.[/dim]")
|
||||
|
||||
console.print()
|
||||
console.print("[cyan]1.[/cyan] Add source")
|
||||
console.print("[cyan]2.[/cyan] Edit source")
|
||||
console.print("[cyan]3.[/cyan] Remove source")
|
||||
console.print("[cyan]4.[/cyan] Test source")
|
||||
console.print("[cyan]0.[/cyan] Back")
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask("Select option", default=0)
|
||||
|
||||
if choice == 0:
|
||||
return
|
||||
elif choice == 1:
|
||||
self._add_mesh_source()
|
||||
elif choice == 2:
|
||||
self._edit_mesh_source()
|
||||
elif choice == 3:
|
||||
self._remove_mesh_source()
|
||||
elif choice == 4:
|
||||
self._test_mesh_source()
|
||||
|
||||
def _add_mesh_source(self) -> None:
|
||||
"""Add a new mesh data source."""
|
||||
self._clear()
|
||||
console.print("[bold]Add Mesh Source[/bold]\n")
|
||||
|
||||
# Get name
|
||||
existing_names = {s.name for s in self.config.mesh_sources}
|
||||
while True:
|
||||
name = Prompt.ask("Source name (unique identifier)")
|
||||
if not name:
|
||||
console.print("[yellow]Name is required.[/yellow]")
|
||||
continue
|
||||
if name in existing_names:
|
||||
console.print(f"[yellow]Name '{name}' already exists. Choose another.[/yellow]")
|
||||
continue
|
||||
break
|
||||
|
||||
# Get type
|
||||
console.print("\n[cyan]1.[/cyan] meshview - Meshview instance")
|
||||
console.print("[cyan]2.[/cyan] meshmonitor - MeshMonitor instance")
|
||||
type_choice = IntPrompt.ask("Source type", default=1)
|
||||
source_type = "meshview" if type_choice == 1 else "meshmonitor"
|
||||
|
||||
# Get URL
|
||||
url = Prompt.ask("URL (e.g., https://meshview.example.com or http://192.168.1.100:3333)")
|
||||
|
||||
# Get API token (MeshMonitor only)
|
||||
api_token = ""
|
||||
if source_type == "meshmonitor":
|
||||
console.print("\n[dim]API token is required for MeshMonitor. Use ${ENV_VAR} for env vars.[/dim]")
|
||||
api_token = Prompt.ask("API token", default="")
|
||||
|
||||
# Get refresh interval
|
||||
refresh_interval = IntPrompt.ask("Refresh interval (seconds)", default=300)
|
||||
|
||||
# Create and add source
|
||||
source = MeshSourceConfig(
|
||||
name=name,
|
||||
type=source_type,
|
||||
url=url,
|
||||
api_token=api_token,
|
||||
refresh_interval=refresh_interval,
|
||||
enabled=True,
|
||||
)
|
||||
self.config.mesh_sources.append(source)
|
||||
self.modified = True
|
||||
|
||||
console.print(f"\n[green]Source '{name}' added.[/green]")
|
||||
input("Press Enter to continue...")
|
||||
|
||||
def _edit_mesh_source(self) -> None:
|
||||
"""Edit an existing mesh data source."""
|
||||
if not self.config.mesh_sources:
|
||||
console.print("[yellow]No sources to edit.[/yellow]")
|
||||
input("\nPress Enter to continue...")
|
||||
return
|
||||
|
||||
self._clear()
|
||||
console.print("[bold]Edit Mesh Source[/bold]\n")
|
||||
|
||||
# Show list
|
||||
for i, src in enumerate(self.config.mesh_sources, 1):
|
||||
status = "[green]enabled[/green]" if src.enabled else "[red]disabled[/red]"
|
||||
console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type}) - {status}")
|
||||
|
||||
console.print("[cyan]0.[/cyan] Cancel")
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask("Select source to edit", default=0)
|
||||
if choice == 0 or choice > len(self.config.mesh_sources):
|
||||
return
|
||||
|
||||
src = self.config.mesh_sources[choice - 1]
|
||||
|
||||
while True:
|
||||
self._clear()
|
||||
console.print(f"[bold]Edit Source: {src.name}[/bold]\n")
|
||||
|
||||
table = Table(box=box.ROUNDED)
|
||||
table.add_column("Option", style="cyan", width=4)
|
||||
table.add_column("Setting", style="white")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("1", "Name", src.name)
|
||||
table.add_row("2", "Type", src.type)
|
||||
table.add_row("3", "URL", src.url)
|
||||
if src.type == "meshmonitor":
|
||||
token_display = "****" + src.api_token[-4:] if len(src.api_token) > 4 else src.api_token or "[dim]not set[/dim]"
|
||||
table.add_row("4", "API Token", token_display)
|
||||
table.add_row("5", "Refresh Interval", f"{src.refresh_interval}s")
|
||||
table.add_row("6", "Enabled", self._status_icon(src.enabled))
|
||||
table.add_row("0", "Back", "")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
opt = IntPrompt.ask("Select option", default=0)
|
||||
|
||||
if opt == 0:
|
||||
return
|
||||
elif opt == 1:
|
||||
existing_names = {s.name for s in self.config.mesh_sources if s != src}
|
||||
value = Prompt.ask("Name", default=src.name)
|
||||
if value and value not in existing_names:
|
||||
src.name = value
|
||||
self.modified = True
|
||||
elif value in existing_names:
|
||||
console.print("[yellow]Name already exists.[/yellow]")
|
||||
elif opt == 2:
|
||||
console.print("\n[cyan]1.[/cyan] meshview")
|
||||
console.print("[cyan]2.[/cyan] meshmonitor")
|
||||
t = IntPrompt.ask("Type", default=1 if src.type == "meshview" else 2)
|
||||
new_type = "meshview" if t == 1 else "meshmonitor"
|
||||
if new_type != src.type:
|
||||
src.type = new_type
|
||||
self.modified = True
|
||||
elif opt == 3:
|
||||
value = Prompt.ask("URL", default=src.url)
|
||||
if value != src.url:
|
||||
src.url = value
|
||||
self.modified = True
|
||||
elif opt == 4 and src.type == "meshmonitor":
|
||||
value = Prompt.ask("API Token", default=src.api_token)
|
||||
if value != src.api_token:
|
||||
src.api_token = value
|
||||
self.modified = True
|
||||
elif opt == 5:
|
||||
value = IntPrompt.ask("Refresh interval (seconds)", default=src.refresh_interval)
|
||||
if value != src.refresh_interval:
|
||||
src.refresh_interval = value
|
||||
self.modified = True
|
||||
elif opt == 6:
|
||||
src.enabled = not src.enabled
|
||||
self.modified = True
|
||||
|
||||
def _remove_mesh_source(self) -> None:
|
||||
"""Remove a mesh data source."""
|
||||
if not self.config.mesh_sources:
|
||||
console.print("[yellow]No sources to remove.[/yellow]")
|
||||
input("\nPress Enter to continue...")
|
||||
return
|
||||
|
||||
self._clear()
|
||||
console.print("[bold]Remove Mesh Source[/bold]\n")
|
||||
|
||||
# Show list
|
||||
for i, src in enumerate(self.config.mesh_sources, 1):
|
||||
console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})")
|
||||
|
||||
console.print("[cyan]0.[/cyan] Cancel")
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask("Select source to remove", default=0)
|
||||
if choice == 0 or choice > len(self.config.mesh_sources):
|
||||
return
|
||||
|
||||
src = self.config.mesh_sources[choice - 1]
|
||||
if Confirm.ask(f"Remove source '{src.name}'?", default=False):
|
||||
self.config.mesh_sources.pop(choice - 1)
|
||||
self.modified = True
|
||||
console.print(f"[green]Source '{src.name}' removed.[/green]")
|
||||
input("Press Enter to continue...")
|
||||
|
||||
def _test_mesh_source(self) -> None:
|
||||
"""Test a mesh data source connection."""
|
||||
if not self.config.mesh_sources:
|
||||
console.print("[yellow]No sources to test.[/yellow]")
|
||||
input("\nPress Enter to continue...")
|
||||
return
|
||||
|
||||
self._clear()
|
||||
console.print("[bold]Test Mesh Source[/bold]\n")
|
||||
|
||||
# Show list
|
||||
for i, src in enumerate(self.config.mesh_sources, 1):
|
||||
console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})")
|
||||
|
||||
console.print("[cyan]0.[/cyan] Cancel")
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask("Select source to test", default=0)
|
||||
if choice == 0 or choice > len(self.config.mesh_sources):
|
||||
return
|
||||
|
||||
src = self.config.mesh_sources[choice - 1]
|
||||
console.print(f"\n[dim]Testing {src.name} ({src.url})...[/dim]\n")
|
||||
|
||||
try:
|
||||
if src.type == "meshview":
|
||||
from ..sources.meshview import MeshviewSource
|
||||
source = MeshviewSource(url=src.url, refresh_interval=src.refresh_interval)
|
||||
else:
|
||||
from ..sources.meshmonitor_data import MeshMonitorDataSource
|
||||
source = MeshMonitorDataSource(
|
||||
url=src.url,
|
||||
api_token=src.api_token,
|
||||
refresh_interval=src.refresh_interval,
|
||||
)
|
||||
|
||||
success = source.fetch_all()
|
||||
|
||||
if success:
|
||||
console.print("[green]Connection successful![/green]\n")
|
||||
console.print(f" Nodes: {len(source.nodes)}")
|
||||
if src.type == "meshview":
|
||||
console.print(f" Edges: {len(source.edges)}")
|
||||
console.print(f" Stats: {'loaded' if source.stats else 'none'}")
|
||||
console.print(f" Counts: {'loaded' if source.counts else 'none'}")
|
||||
else:
|
||||
console.print(f" Channels: {len(source.channels)}")
|
||||
console.print(f" Telemetry: {len(source.telemetry)}")
|
||||
console.print(f" Traceroutes: {len(source.traceroutes)}")
|
||||
console.print(f" Packets: {len(source.packets)}")
|
||||
else:
|
||||
console.print(f"[red]Connection failed: {source.last_error}[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
|
||||
input("\nPress Enter to continue...")
|
||||
|
||||
def _setup_wizard(self) -> None:
|
||||
"""First-time setup wizard."""
|
||||
self._clear()
|
||||
|
|
|
|||
|
|
@ -163,6 +163,19 @@ class KnowledgeConfig:
|
|||
db_path: str = ""
|
||||
top_k: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeshSourceConfig:
|
||||
"""Configuration for a mesh data source."""
|
||||
|
||||
name: str = ""
|
||||
type: str = "" # "meshview" or "meshmonitor"
|
||||
url: str = ""
|
||||
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
|
||||
refresh_interval: int = 300
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Main configuration container."""
|
||||
|
|
@ -178,6 +191,7 @@ class Config:
|
|||
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
||||
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
|
||||
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
||||
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
|
||||
|
||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||
|
||||
|
|
@ -215,6 +229,13 @@ def _dict_to_dataclass(cls, data: dict):
|
|||
# Handle nested dataclasses
|
||||
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(field_type, value)
|
||||
# Handle list of MeshSourceConfig
|
||||
elif key == "mesh_sources" and isinstance(value, list):
|
||||
kwargs[key] = [
|
||||
_dict_to_dataclass(MeshSourceConfig, item)
|
||||
if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
kwargs[key] = value
|
||||
|
||||
|
|
@ -234,7 +255,11 @@ def _dataclass_to_dict(obj) -> dict:
|
|||
if hasattr(value, "__dataclass_fields__"):
|
||||
result[field_name] = _dataclass_to_dict(value)
|
||||
elif isinstance(value, list):
|
||||
result[field_name] = list(value)
|
||||
# Handle list of dataclasses (like mesh_sources)
|
||||
result[field_name] = [
|
||||
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[field_name] = value
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class MeshAI:
|
|||
self.context: Optional[MeshContext] = None
|
||||
self.meshmonitor_sync = None
|
||||
self.knowledge = None
|
||||
self.source_manager = None
|
||||
self.router: Optional[MessageRouter] = None
|
||||
self.responder: Optional[Responder] = None
|
||||
self._running = False
|
||||
|
|
@ -78,6 +79,10 @@ class MeshAI:
|
|||
if self.meshmonitor_sync:
|
||||
self.meshmonitor_sync.maybe_refresh()
|
||||
|
||||
# Periodic mesh source refresh
|
||||
if self.source_manager:
|
||||
self.source_manager.refresh_all()
|
||||
|
||||
# Periodic cleanup
|
||||
if time.time() - self._last_cleanup >= 3600:
|
||||
await self.history.cleanup_expired()
|
||||
|
|
@ -178,6 +183,28 @@ class MeshAI:
|
|||
else:
|
||||
self.meshmonitor_sync = None
|
||||
|
||||
# Mesh data sources
|
||||
enabled_sources = [s for s in self.config.mesh_sources if s.enabled]
|
||||
if enabled_sources:
|
||||
from .mesh_sources import MeshSourceManager
|
||||
self.source_manager = MeshSourceManager(enabled_sources)
|
||||
# Initial fetch
|
||||
self.source_manager.refresh_all()
|
||||
# Log status
|
||||
for status in self.source_manager.get_status():
|
||||
if status["is_loaded"]:
|
||||
logger.info(
|
||||
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||
f"{status['node_count']} nodes"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||
f"failed - {status.get('last_error', 'unknown error')}"
|
||||
)
|
||||
else:
|
||||
self.source_manager = None
|
||||
|
||||
# Knowledge base
|
||||
kb_cfg = self.config.knowledge
|
||||
if kb_cfg.enabled and kb_cfg.db_path:
|
||||
|
|
@ -195,6 +222,7 @@ class MeshAI:
|
|||
context=self.context,
|
||||
meshmonitor_sync=self.meshmonitor_sync,
|
||||
knowledge=self.knowledge,
|
||||
source_manager=self.source_manager,
|
||||
)
|
||||
|
||||
# Responder
|
||||
|
|
|
|||
197
meshai/mesh_sources.py
Normal file
197
meshai/mesh_sources.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Mesh data source manager."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .config import MeshSourceConfig
|
||||
from .sources.meshview import MeshviewSource
|
||||
from .sources.meshmonitor_data import MeshMonitorDataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshSourceManager:
|
||||
"""Manages multiple mesh data sources."""
|
||||
|
||||
def __init__(self, source_configs: list[MeshSourceConfig]):
|
||||
"""Initialize source manager.
|
||||
|
||||
Args:
|
||||
source_configs: List of source configurations
|
||||
"""
|
||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {}
|
||||
|
||||
for cfg in source_configs:
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
|
||||
name = cfg.name
|
||||
if not name:
|
||||
logger.warning("Skipping source with empty name")
|
||||
continue
|
||||
|
||||
if name in self._sources:
|
||||
logger.warning(f"Duplicate source name '{name}', skipping")
|
||||
continue
|
||||
|
||||
try:
|
||||
if cfg.type == "meshview":
|
||||
self._sources[name] = MeshviewSource(
|
||||
url=cfg.url,
|
||||
refresh_interval=cfg.refresh_interval,
|
||||
)
|
||||
logger.info(f"Created Meshview source '{name}' -> {cfg.url}")
|
||||
|
||||
elif cfg.type == "meshmonitor":
|
||||
self._sources[name] = MeshMonitorDataSource(
|
||||
url=cfg.url,
|
||||
api_token=cfg.api_token,
|
||||
refresh_interval=cfg.refresh_interval,
|
||||
)
|
||||
logger.info(f"Created MeshMonitor source '{name}' -> {cfg.url}")
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown source type '{cfg.type}' for '{name}'")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create source '{name}': {e}")
|
||||
|
||||
def refresh_all(self) -> int:
|
||||
"""Call maybe_refresh() on all sources.
|
||||
|
||||
Returns:
|
||||
Number of sources that refreshed
|
||||
"""
|
||||
refreshed = 0
|
||||
for name, source in self._sources.items():
|
||||
try:
|
||||
if source.maybe_refresh():
|
||||
refreshed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing source '{name}': {e}")
|
||||
return refreshed
|
||||
|
||||
def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]:
|
||||
"""Get a specific source by name.
|
||||
|
||||
Args:
|
||||
name: Source name
|
||||
|
||||
Returns:
|
||||
Source instance or None if not found
|
||||
"""
|
||||
return self._sources.get(name)
|
||||
|
||||
def get_all_nodes(self) -> list[dict]:
|
||||
"""Get nodes from all sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of node dicts with '_source' field added
|
||||
"""
|
||||
all_nodes = []
|
||||
for name, source in self._sources.items():
|
||||
for node in source.nodes:
|
||||
tagged = dict(node)
|
||||
tagged["_source"] = name
|
||||
all_nodes.append(tagged)
|
||||
return all_nodes
|
||||
|
||||
def get_all_edges(self) -> list[dict]:
|
||||
"""Get edges from all Meshview sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of edge dicts with '_source' field added
|
||||
"""
|
||||
all_edges = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshviewSource):
|
||||
for edge in source.edges:
|
||||
tagged = dict(edge)
|
||||
tagged["_source"] = name
|
||||
all_edges.append(tagged)
|
||||
return all_edges
|
||||
|
||||
def get_all_telemetry(self) -> list[dict]:
|
||||
"""Get telemetry from all MeshMonitor sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of telemetry dicts with '_source' field added
|
||||
"""
|
||||
all_telemetry = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
for item in source.telemetry:
|
||||
tagged = dict(item)
|
||||
tagged["_source"] = name
|
||||
all_telemetry.append(tagged)
|
||||
return all_telemetry
|
||||
|
||||
def get_all_traceroutes(self) -> list[dict]:
|
||||
"""Get traceroutes from all MeshMonitor sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of traceroute dicts with '_source' field added
|
||||
"""
|
||||
all_traceroutes = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
for item in source.traceroutes:
|
||||
tagged = dict(item)
|
||||
tagged["_source"] = name
|
||||
all_traceroutes.append(tagged)
|
||||
return all_traceroutes
|
||||
|
||||
def get_all_channels(self) -> list[dict]:
|
||||
"""Get channels from all MeshMonitor sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of channel dicts with '_source' field added
|
||||
"""
|
||||
all_channels = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
for item in source.channels:
|
||||
tagged = dict(item)
|
||||
tagged["_source"] = name
|
||||
all_channels.append(tagged)
|
||||
return all_channels
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
"""Get status of all sources for TUI display.
|
||||
|
||||
Returns:
|
||||
List of status dicts with source info
|
||||
"""
|
||||
status_list = []
|
||||
for name, source in self._sources.items():
|
||||
status = {
|
||||
"name": name,
|
||||
"type": "meshview" if isinstance(source, MeshviewSource) else "meshmonitor",
|
||||
"enabled": True,
|
||||
"is_loaded": source.is_loaded,
|
||||
"last_refresh": source.last_refresh,
|
||||
"last_error": source.last_error,
|
||||
"node_count": len(source.nodes),
|
||||
}
|
||||
|
||||
if isinstance(source, MeshviewSource):
|
||||
status["edge_count"] = len(source.edges)
|
||||
elif isinstance(source, MeshMonitorDataSource):
|
||||
status["telemetry_count"] = len(source.telemetry)
|
||||
status["traceroute_count"] = len(source.traceroutes)
|
||||
status["channel_count"] = len(source.channels)
|
||||
|
||||
status_list.append(status)
|
||||
|
||||
return status_list
|
||||
|
||||
@property
|
||||
def source_count(self) -> int:
|
||||
"""Get number of active sources."""
|
||||
return len(self._sources)
|
||||
|
||||
@property
|
||||
def source_names(self) -> list[str]:
|
||||
"""Get list of source names."""
|
||||
return list(self._sources.keys())
|
||||
|
|
@ -67,6 +67,7 @@ class MessageRouter:
|
|||
context: MeshContext = None,
|
||||
meshmonitor_sync=None,
|
||||
knowledge=None,
|
||||
source_manager=None,
|
||||
):
|
||||
self.config = config
|
||||
self.connector = connector
|
||||
|
|
@ -76,6 +77,7 @@ class MessageRouter:
|
|||
self.context = context
|
||||
self.meshmonitor_sync = meshmonitor_sync
|
||||
self.knowledge = knowledge
|
||||
self.source_manager = source_manager # For future use in Phase 3
|
||||
self.continuations = ContinuationState(max_continuations=3)
|
||||
|
||||
|
||||
|
|
|
|||
1
meshai/sources/__init__.py
Normal file
1
meshai/sources/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Mesh data source connectors."""
|
||||
257
meshai/sources/meshmonitor_data.py
Normal file
257
meshai/sources/meshmonitor_data.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""MeshMonitor API data source."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshMonitorDataSource:
|
||||
"""Fetches mesh data from a MeshMonitor instance."""
|
||||
|
||||
def __init__(self, url: str, api_token: str, refresh_interval: int = 300):
|
||||
"""Initialize MeshMonitor data source.
|
||||
|
||||
Args:
|
||||
url: Base URL of MeshMonitor instance (e.g., http://192.168.1.100:3333)
|
||||
api_token: API token for authentication. Supports ${ENV_VAR} format.
|
||||
refresh_interval: Seconds between refresh checks (default 5 minutes)
|
||||
"""
|
||||
self._url = url.rstrip("/")
|
||||
self._api_token = self._resolve_token(api_token)
|
||||
self._refresh_interval = refresh_interval
|
||||
|
||||
# Cached data
|
||||
self._nodes: list[dict] = []
|
||||
self._channels: list[dict] = []
|
||||
self._telemetry: list[dict] = []
|
||||
self._traceroutes: list[dict] = []
|
||||
self._network_stats: Optional[dict] = None
|
||||
self._topology: Optional[dict] = None
|
||||
self._packets: list[dict] = []
|
||||
self._solar: list[dict] = []
|
||||
|
||||
self._last_refresh: float = 0.0
|
||||
self._last_error: Optional[str] = None
|
||||
self._is_loaded: bool = False
|
||||
|
||||
def _resolve_token(self, token: str) -> str:
|
||||
"""Resolve token, supporting ${ENV_VAR} format.
|
||||
|
||||
Args:
|
||||
token: API token or env var reference
|
||||
|
||||
Returns:
|
||||
Resolved token value
|
||||
"""
|
||||
if token.startswith("${") and token.endswith("}"):
|
||||
env_var = token[2:-1]
|
||||
return os.environ.get(env_var, "")
|
||||
return token
|
||||
|
||||
@property
|
||||
def nodes(self) -> list[dict]:
|
||||
"""Get cached nodes list."""
|
||||
return self._nodes
|
||||
|
||||
@property
|
||||
def channels(self) -> list[dict]:
|
||||
"""Get cached channels list."""
|
||||
return self._channels
|
||||
|
||||
@property
|
||||
def telemetry(self) -> list[dict]:
|
||||
"""Get cached telemetry list."""
|
||||
return self._telemetry
|
||||
|
||||
@property
|
||||
def traceroutes(self) -> list[dict]:
|
||||
"""Get cached traceroutes list."""
|
||||
return self._traceroutes
|
||||
|
||||
@property
|
||||
def network_stats(self) -> Optional[dict]:
|
||||
"""Get cached network stats."""
|
||||
return self._network_stats
|
||||
|
||||
@property
|
||||
def topology(self) -> Optional[dict]:
|
||||
"""Get cached topology."""
|
||||
return self._topology
|
||||
|
||||
@property
|
||||
def packets(self) -> list[dict]:
|
||||
"""Get cached packets list."""
|
||||
return self._packets
|
||||
|
||||
@property
|
||||
def solar(self) -> list[dict]:
|
||||
"""Get cached solar estimates list."""
|
||||
return self._solar
|
||||
|
||||
@property
|
||||
def last_refresh(self) -> float:
|
||||
"""Get last refresh timestamp (epoch)."""
|
||||
return self._last_refresh
|
||||
|
||||
@property
|
||||
def last_error(self) -> Optional[str]:
|
||||
"""Get last error message if any."""
|
||||
return self._last_error
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if data has been successfully loaded."""
|
||||
return self._is_loaded
|
||||
|
||||
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
|
||||
"""Fetch JSON from an endpoint with Bearer auth.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path (e.g., /api/v1/nodes)
|
||||
|
||||
Returns:
|
||||
Parsed JSON data or None on error
|
||||
"""
|
||||
url = f"{self._url}{endpoint}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {self._api_token}",
|
||||
}
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# MeshMonitor wraps responses in {"success": true, "data": [...]}
|
||||
# Extract the actual data if wrapped
|
||||
if isinstance(data, dict) and "data" in data:
|
||||
return data["data"]
|
||||
return data
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning(f"MeshMonitor {endpoint}: HTTP {e.code} {e.reason}")
|
||||
return None
|
||||
except URLError as e:
|
||||
logger.warning(f"MeshMonitor {endpoint}: Connection error - {e.reason}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"MeshMonitor {endpoint}: Invalid JSON - {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"MeshMonitor {endpoint}: {e}")
|
||||
return None
|
||||
|
||||
def fetch_all(self) -> bool:
|
||||
"""Fetch all data from MeshMonitor API.
|
||||
|
||||
Fetches all endpoints independently. One failure doesn't block others.
|
||||
|
||||
Returns:
|
||||
True if at least one endpoint succeeded
|
||||
"""
|
||||
success_count = 0
|
||||
errors = []
|
||||
|
||||
# Fetch nodes
|
||||
data = self._fetch_json("/api/v1/nodes")
|
||||
if data is not None:
|
||||
self._nodes = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._nodes)} nodes")
|
||||
else:
|
||||
errors.append("nodes")
|
||||
|
||||
# Fetch channels
|
||||
data = self._fetch_json("/api/v1/channels")
|
||||
if data is not None:
|
||||
self._channels = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._channels)} channels")
|
||||
else:
|
||||
errors.append("channels")
|
||||
|
||||
# Fetch telemetry
|
||||
data = self._fetch_json("/api/v1/telemetry")
|
||||
if data is not None:
|
||||
self._telemetry = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._telemetry)} telemetry records")
|
||||
else:
|
||||
errors.append("telemetry")
|
||||
|
||||
# Fetch traceroutes
|
||||
data = self._fetch_json("/api/v1/traceroutes")
|
||||
if data is not None:
|
||||
self._traceroutes = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._traceroutes)} traceroutes")
|
||||
else:
|
||||
errors.append("traceroutes")
|
||||
|
||||
# Fetch network stats
|
||||
data = self._fetch_json("/api/v1/network")
|
||||
if data is not None:
|
||||
self._network_stats = data if isinstance(data, dict) else None
|
||||
success_count += 1
|
||||
logger.debug("MeshMonitor: fetched network stats")
|
||||
else:
|
||||
errors.append("network")
|
||||
|
||||
# Fetch topology
|
||||
data = self._fetch_json("/api/v1/network/topology")
|
||||
if data is not None:
|
||||
self._topology = data if isinstance(data, dict) else None
|
||||
success_count += 1
|
||||
logger.debug("MeshMonitor: fetched topology")
|
||||
else:
|
||||
errors.append("topology")
|
||||
|
||||
# Fetch packets
|
||||
data = self._fetch_json("/api/v1/packets")
|
||||
if data is not None:
|
||||
self._packets = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._packets)} packets")
|
||||
else:
|
||||
errors.append("packets")
|
||||
|
||||
# Fetch solar estimates
|
||||
data = self._fetch_json("/api/v1/solar")
|
||||
if data is not None:
|
||||
self._solar = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"MeshMonitor: fetched {len(self._solar)} solar estimates")
|
||||
else:
|
||||
errors.append("solar")
|
||||
|
||||
# Update state
|
||||
self._last_refresh = time.time()
|
||||
|
||||
if success_count > 0:
|
||||
self._is_loaded = True
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
f"MeshMonitor refresh: {len(self._nodes)} nodes, "
|
||||
f"{len(self._telemetry)} telemetry, {len(self._traceroutes)} traceroutes"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._last_error = f"All endpoints failed: {', '.join(errors)}"
|
||||
logger.error(f"MeshMonitor: {self._last_error}")
|
||||
return False
|
||||
|
||||
def maybe_refresh(self) -> bool:
|
||||
"""Refresh data if interval has elapsed.
|
||||
|
||||
Returns:
|
||||
True if refresh was performed
|
||||
"""
|
||||
if time.time() - self._last_refresh >= self._refresh_interval:
|
||||
return self.fetch_all()
|
||||
return False
|
||||
166
meshai/sources/meshview.py
Normal file
166
meshai/sources/meshview.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Meshview API data source."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshviewSource:
|
||||
"""Fetches mesh data from a Meshview instance."""
|
||||
|
||||
def __init__(self, url: str, refresh_interval: int = 300):
|
||||
"""Initialize Meshview source.
|
||||
|
||||
Args:
|
||||
url: Base URL of Meshview instance (e.g., https://meshview.example.com)
|
||||
refresh_interval: Seconds between refresh checks (default 5 minutes)
|
||||
"""
|
||||
self._url = url.rstrip("/")
|
||||
self._refresh_interval = refresh_interval
|
||||
self._nodes: list[dict] = []
|
||||
self._edges: list[dict] = []
|
||||
self._stats: Optional[dict | list] = None
|
||||
self._counts: Optional[dict] = None
|
||||
self._last_refresh: float = 0.0
|
||||
self._last_error: Optional[str] = None
|
||||
self._is_loaded: bool = False
|
||||
|
||||
@property
|
||||
def nodes(self) -> list[dict]:
|
||||
"""Get cached nodes list."""
|
||||
return self._nodes
|
||||
|
||||
@property
|
||||
def edges(self) -> list[dict]:
|
||||
"""Get cached edges list."""
|
||||
return self._edges
|
||||
|
||||
@property
|
||||
def stats(self) -> Optional[dict | list]:
|
||||
"""Get cached stats."""
|
||||
return self._stats
|
||||
|
||||
@property
|
||||
def counts(self) -> Optional[dict]:
|
||||
"""Get cached counts."""
|
||||
return self._counts
|
||||
|
||||
@property
|
||||
def last_refresh(self) -> float:
|
||||
"""Get last refresh timestamp (epoch)."""
|
||||
return self._last_refresh
|
||||
|
||||
@property
|
||||
def last_error(self) -> Optional[str]:
|
||||
"""Get last error message if any."""
|
||||
return self._last_error
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if data has been successfully loaded."""
|
||||
return self._is_loaded
|
||||
|
||||
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
|
||||
"""Fetch JSON from an endpoint.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path (e.g., /api/nodes)
|
||||
|
||||
Returns:
|
||||
Parsed JSON data or None on error
|
||||
"""
|
||||
url = f"{self._url}{endpoint}"
|
||||
try:
|
||||
req = Request(url, headers={"Accept": "application/json"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
logger.warning(f"Meshview {endpoint}: HTTP {e.code} {e.reason}")
|
||||
return None
|
||||
except URLError as e:
|
||||
logger.warning(f"Meshview {endpoint}: Connection error - {e.reason}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Meshview {endpoint}: Invalid JSON - {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Meshview {endpoint}: {e}")
|
||||
return None
|
||||
|
||||
def fetch_all(self) -> bool:
|
||||
"""Fetch all data from Meshview API.
|
||||
|
||||
Fetches nodes, edges, stats, and counts independently.
|
||||
One failure doesn't block others.
|
||||
|
||||
Returns:
|
||||
True if at least one endpoint succeeded
|
||||
"""
|
||||
success_count = 0
|
||||
errors = []
|
||||
|
||||
# Fetch nodes
|
||||
data = self._fetch_json("/api/nodes")
|
||||
if data is not None:
|
||||
self._nodes = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"Meshview: fetched {len(self._nodes)} nodes")
|
||||
else:
|
||||
errors.append("nodes")
|
||||
|
||||
# Fetch edges
|
||||
data = self._fetch_json("/api/edges")
|
||||
if data is not None:
|
||||
self._edges = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
logger.debug(f"Meshview: fetched {len(self._edges)} edges")
|
||||
else:
|
||||
errors.append("edges")
|
||||
|
||||
# Fetch stats (24h hourly)
|
||||
data = self._fetch_json("/api/stats?period_type=hour&length=24")
|
||||
if data is not None:
|
||||
self._stats = data
|
||||
success_count += 1
|
||||
logger.debug("Meshview: fetched stats")
|
||||
else:
|
||||
errors.append("stats")
|
||||
|
||||
# Fetch counts
|
||||
data = self._fetch_json("/api/stats/count")
|
||||
if data is not None:
|
||||
self._counts = data if isinstance(data, dict) else None
|
||||
success_count += 1
|
||||
logger.debug("Meshview: fetched counts")
|
||||
else:
|
||||
errors.append("counts")
|
||||
|
||||
# Update state
|
||||
self._last_refresh = time.time()
|
||||
|
||||
if success_count > 0:
|
||||
self._is_loaded = True
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
f"Meshview refresh: {len(self._nodes)} nodes, {len(self._edges)} edges"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._last_error = f"All endpoints failed: {', '.join(errors)}"
|
||||
logger.error(f"Meshview: {self._last_error}")
|
||||
return False
|
||||
|
||||
def maybe_refresh(self) -> bool:
|
||||
"""Refresh data if interval has elapsed.
|
||||
|
||||
Returns:
|
||||
True if refresh was performed
|
||||
"""
|
||||
if time.time() - self._last_refresh >= self._refresh_interval:
|
||||
return self.fetch_all()
|
||||
return False
|
||||
Loading…
Add table
Add a link
Reference in a new issue