mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +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 INTEGRATION ===
|
||||||
meshmonitor:
|
meshmonitor:
|
||||||
enabled: false # Enable MeshMonitor trigger sync
|
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
|
inject_into_prompt: true # Include trigger list in LLM prompt
|
||||||
refresh_interval: 300 # Seconds between trigger refreshes
|
refresh_interval: 300 # Seconds between trigger refreshes
|
||||||
|
|
||||||
|
|
@ -80,5 +80,23 @@ knowledge:
|
||||||
enabled: false # Enable knowledge base search
|
enabled: false # Enable knowledge base search
|
||||||
db_path: "" # Path to knowledge SQLite database
|
db_path: "" # Path to knowledge SQLite database
|
||||||
top_k: 5 # Number of chunks to retrieve per query
|
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."""
|
"""Rich-based TUI configurator for MeshAI."""
|
||||||
|
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ from rich.prompt import Confirm, IntPrompt, Prompt
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from ..config import Config, load_config, save_config
|
from ..config import Config, MeshSourceConfig, load_config, save_config
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
@ -84,7 +85,14 @@ class Configurator:
|
||||||
kb_status = self._status_icon(self.config.knowledge.enabled)
|
kb_status = self._status_icon(self.config.knowledge.enabled)
|
||||||
kb_path = self.config.knowledge.db_path or "[dim]not set[/dim]"
|
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("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(table)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
@ -93,13 +101,13 @@ class Configurator:
|
||||||
if self.modified:
|
if self.modified:
|
||||||
console.print("[yellow]* Unsaved changes[/yellow]")
|
console.print("[yellow]* Unsaved changes[/yellow]")
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[white]12. Save[/white] [dim]Save config, stay in menu[/dim]")
|
console.print("[white]13. Save[/white] [dim]Save config, stay in menu[/dim]")
|
||||||
console.print("[green]13. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
|
console.print("[green]14. 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. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
|
||||||
console.print("[white]15. Exit without Saving[/white]")
|
console.print("[white]16. Exit without Saving[/white]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
choice = IntPrompt.ask("Select option", default=13)
|
choice = IntPrompt.ask("Select option", default=14)
|
||||||
|
|
||||||
if choice == 1:
|
if choice == 1:
|
||||||
self._bot_settings()
|
self._bot_settings()
|
||||||
|
|
@ -122,15 +130,17 @@ class Configurator:
|
||||||
elif choice == 10:
|
elif choice == 10:
|
||||||
self._knowledge_settings()
|
self._knowledge_settings()
|
||||||
elif choice == 11:
|
elif choice == 11:
|
||||||
self._setup_wizard()
|
self._mesh_sources_settings()
|
||||||
elif choice == 12:
|
elif choice == 12:
|
||||||
self._save_only()
|
self._setup_wizard()
|
||||||
elif choice == 13:
|
elif choice == 13:
|
||||||
self._save_and_restart()
|
self._save_only()
|
||||||
elif choice == 14:
|
elif choice == 14:
|
||||||
|
self._save_and_restart()
|
||||||
|
elif choice == 15:
|
||||||
self._save_restart_exit()
|
self._save_restart_exit()
|
||||||
break
|
break
|
||||||
elif choice == 15:
|
elif choice == 16:
|
||||||
break
|
break
|
||||||
|
|
||||||
def _show_header(self) -> None:
|
def _show_header(self) -> None:
|
||||||
|
|
@ -728,6 +738,276 @@ class Configurator:
|
||||||
self.config.knowledge.top_k = value
|
self.config.knowledge.top_k = value
|
||||||
self.modified = True
|
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:
|
def _setup_wizard(self) -> None:
|
||||||
"""First-time setup wizard."""
|
"""First-time setup wizard."""
|
||||||
self._clear()
|
self._clear()
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,19 @@ class KnowledgeConfig:
|
||||||
db_path: str = ""
|
db_path: str = ""
|
||||||
top_k: int = 5
|
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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""Main configuration container."""
|
"""Main configuration container."""
|
||||||
|
|
@ -178,6 +191,7 @@ class Config:
|
||||||
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
||||||
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
|
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
|
||||||
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
||||||
|
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
|
||||||
|
|
||||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
@ -215,6 +229,13 @@ def _dict_to_dataclass(cls, data: dict):
|
||||||
# Handle nested dataclasses
|
# Handle nested dataclasses
|
||||||
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
|
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
|
||||||
kwargs[key] = _dict_to_dataclass(field_type, value)
|
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:
|
else:
|
||||||
kwargs[key] = value
|
kwargs[key] = value
|
||||||
|
|
||||||
|
|
@ -234,7 +255,11 @@ def _dataclass_to_dict(obj) -> dict:
|
||||||
if hasattr(value, "__dataclass_fields__"):
|
if hasattr(value, "__dataclass_fields__"):
|
||||||
result[field_name] = _dataclass_to_dict(value)
|
result[field_name] = _dataclass_to_dict(value)
|
||||||
elif isinstance(value, list):
|
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:
|
else:
|
||||||
result[field_name] = value
|
result[field_name] = value
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class MeshAI:
|
||||||
self.context: Optional[MeshContext] = None
|
self.context: Optional[MeshContext] = None
|
||||||
self.meshmonitor_sync = None
|
self.meshmonitor_sync = None
|
||||||
self.knowledge = None
|
self.knowledge = None
|
||||||
|
self.source_manager = None
|
||||||
self.router: Optional[MessageRouter] = None
|
self.router: Optional[MessageRouter] = None
|
||||||
self.responder: Optional[Responder] = None
|
self.responder: Optional[Responder] = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
@ -78,6 +79,10 @@ class MeshAI:
|
||||||
if self.meshmonitor_sync:
|
if self.meshmonitor_sync:
|
||||||
self.meshmonitor_sync.maybe_refresh()
|
self.meshmonitor_sync.maybe_refresh()
|
||||||
|
|
||||||
|
# Periodic mesh source refresh
|
||||||
|
if self.source_manager:
|
||||||
|
self.source_manager.refresh_all()
|
||||||
|
|
||||||
# Periodic cleanup
|
# Periodic cleanup
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
await self.history.cleanup_expired()
|
await self.history.cleanup_expired()
|
||||||
|
|
@ -178,6 +183,28 @@ class MeshAI:
|
||||||
else:
|
else:
|
||||||
self.meshmonitor_sync = None
|
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
|
# Knowledge base
|
||||||
kb_cfg = self.config.knowledge
|
kb_cfg = self.config.knowledge
|
||||||
if kb_cfg.enabled and kb_cfg.db_path:
|
if kb_cfg.enabled and kb_cfg.db_path:
|
||||||
|
|
@ -195,6 +222,7 @@ class MeshAI:
|
||||||
context=self.context,
|
context=self.context,
|
||||||
meshmonitor_sync=self.meshmonitor_sync,
|
meshmonitor_sync=self.meshmonitor_sync,
|
||||||
knowledge=self.knowledge,
|
knowledge=self.knowledge,
|
||||||
|
source_manager=self.source_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Responder
|
# 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,
|
context: MeshContext = None,
|
||||||
meshmonitor_sync=None,
|
meshmonitor_sync=None,
|
||||||
knowledge=None,
|
knowledge=None,
|
||||||
|
source_manager=None,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.connector = connector
|
self.connector = connector
|
||||||
|
|
@ -76,6 +77,7 @@ class MessageRouter:
|
||||||
self.context = context
|
self.context = context
|
||||||
self.meshmonitor_sync = meshmonitor_sync
|
self.meshmonitor_sync = meshmonitor_sync
|
||||||
self.knowledge = knowledge
|
self.knowledge = knowledge
|
||||||
|
self.source_manager = source_manager # For future use in Phase 3
|
||||||
self.continuations = ContinuationState(max_continuations=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