mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
Compare commits
No commits in common. "374fb835c5bd911d22824da2cf28c20f1ac09295" and "914c21e16740bf44920b4321216b3d7b86d8fefa" have entirely different histories.
374fb835c5
...
914c21e167
38 changed files with 103 additions and 2373 deletions
|
|
@ -78,7 +78,7 @@ USER meshai
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
# Expose ttyd web config port
|
# Expose ttyd web config port
|
||||||
EXPOSE 7682 8080
|
EXPOSE 7682
|
||||||
|
|
||||||
# Health check - verify bot process is alive via PID file
|
# Health check - verify bot process is alive via PID file
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,105 @@
|
||||||
# MeshAI Configuration
|
# MeshAI Configuration
|
||||||
# LLM-powered Meshtastic assistant
|
# LLM-powered Meshtastic assistant
|
||||||
#
|
#
|
||||||
# Copy this to config.yaml and customize as needed
|
# Copy this to config.yaml and customize as needed
|
||||||
# For Docker: mount as /data/config.yaml
|
# For Docker: mount as /data/config.yaml
|
||||||
|
|
||||||
# === BOT IDENTITY ===
|
# === BOT IDENTITY ===
|
||||||
bot:
|
bot:
|
||||||
name: ai # Bot's display name
|
name: ai # Bot's display name
|
||||||
owner: "" # Owner's callsign (optional)
|
owner: "" # Owner's callsign (optional)
|
||||||
respond_to_dms: true # Respond to direct messages
|
respond_to_dms: true # Respond to direct messages
|
||||||
filter_bbs_protocols: true # Ignore advBBS sync/notification messages
|
filter_bbs_protocols: true # Ignore advBBS sync/notification messages
|
||||||
|
|
||||||
# === MESHTASTIC CONNECTION ===
|
# === MESHTASTIC CONNECTION ===
|
||||||
connection:
|
connection:
|
||||||
type: tcp # serial | tcp
|
type: tcp # serial | tcp
|
||||||
serial_port: /dev/ttyUSB0 # For serial connection
|
serial_port: /dev/ttyUSB0 # For serial connection
|
||||||
tcp_host: localhost # For TCP connection (meshtasticd)
|
tcp_host: localhost # For TCP connection (meshtasticd)
|
||||||
tcp_port: 4403
|
tcp_port: 4403
|
||||||
|
|
||||||
# === RESPONSE BEHAVIOR ===
|
# === RESPONSE BEHAVIOR ===
|
||||||
response:
|
response:
|
||||||
delay_min: 2.2 # Min delay before responding (seconds)
|
delay_min: 2.2 # Min delay before responding (seconds)
|
||||||
delay_max: 3.0 # Max delay before responding
|
delay_max: 3.0 # Max delay before responding
|
||||||
max_length: 200 # Max chars per message chunk
|
max_length: 200 # Max chars per message chunk
|
||||||
max_messages: 3 # Max message chunks per response
|
max_messages: 3 # Max message chunks per response
|
||||||
|
|
||||||
# === CONVERSATION HISTORY ===
|
# === CONVERSATION HISTORY ===
|
||||||
history:
|
history:
|
||||||
database: /data/conversations.db
|
database: /data/conversations.db
|
||||||
max_messages_per_user: 50 # Messages to keep per user
|
max_messages_per_user: 50 # Messages to keep per user
|
||||||
conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h)
|
conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h)
|
||||||
auto_cleanup: true # Auto-delete old conversations
|
auto_cleanup: true # Auto-delete old conversations
|
||||||
cleanup_interval_hours: 24 # How often to run cleanup
|
cleanup_interval_hours: 24 # How often to run cleanup
|
||||||
max_age_days: 30 # Delete conversations older than this
|
max_age_days: 30 # Delete conversations older than this
|
||||||
|
|
||||||
# === MEMORY OPTIMIZATION ===
|
# === MEMORY OPTIMIZATION ===
|
||||||
memory:
|
memory:
|
||||||
enabled: true # Enable rolling summary memory
|
enabled: true # Enable rolling summary memory
|
||||||
window_size: 4 # Recent message pairs to keep in full
|
window_size: 4 # Recent message pairs to keep in full
|
||||||
summarize_threshold: 8 # Messages before re-summarizing
|
summarize_threshold: 8 # Messages before re-summarizing
|
||||||
|
|
||||||
# === MESH CONTEXT ===
|
# === MESH CONTEXT ===
|
||||||
context:
|
context:
|
||||||
enabled: true # Observe channel traffic for LLM context
|
enabled: true # Observe channel traffic for LLM context
|
||||||
observe_channels: [] # Channel indices to observe (empty = all)
|
observe_channels: [] # Channel indices to observe (empty = all)
|
||||||
ignore_nodes: [] # Node IDs to exclude from observation
|
ignore_nodes: [] # Node IDs to exclude from observation
|
||||||
max_age: 2592000 # Max age in seconds (default 30 days)
|
max_age: 2592000 # Max age in seconds (default 30 days)
|
||||||
max_context_items: 20 # Max observations injected into LLM context
|
max_context_items: 20 # Max observations injected into LLM context
|
||||||
|
|
||||||
# === LLM BACKEND ===
|
# === LLM BACKEND ===
|
||||||
llm:
|
llm:
|
||||||
backend: openai # openai | anthropic | google
|
backend: openai # openai | anthropic | google
|
||||||
api_key: "" # API key (or use LLM_API_KEY env var)
|
api_key: "" # API key (or use LLM_API_KEY env var)
|
||||||
base_url: https://api.openai.com/v1 # API base URL
|
base_url: https://api.openai.com/v1 # API base URL
|
||||||
model: gpt-4o-mini # Model name
|
model: gpt-4o-mini # Model name
|
||||||
timeout: 30 # Request timeout (seconds)
|
timeout: 30 # Request timeout (seconds)
|
||||||
system_prompt: >-
|
system_prompt: >-
|
||||||
You are a helpful assistant on a Meshtastic mesh network.
|
You are a helpful assistant on a Meshtastic mesh network.
|
||||||
Keep responses very brief - 1-2 short sentences, under 300 characters.
|
Keep responses very brief - 1-2 short sentences, under 300 characters.
|
||||||
Only give longer answers if the user explicitly asks for detail or explanation.
|
Only give longer answers if the user explicitly asks for detail or explanation.
|
||||||
Be concise but friendly. No markdown formatting.
|
Be concise but friendly. No markdown formatting.
|
||||||
google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries)
|
google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries)
|
||||||
|
|
||||||
# === WEATHER ===
|
# === WEATHER ===
|
||||||
weather:
|
weather:
|
||||||
primary: openmeteo # openmeteo | wttr | llm
|
primary: openmeteo # openmeteo | wttr | llm
|
||||||
fallback: llm # openmeteo | wttr | llm | none
|
fallback: llm # openmeteo | wttr | llm | none
|
||||||
default_location: "" # Default location for !weather (optional)
|
default_location: "" # Default location for !weather (optional)
|
||||||
|
|
||||||
# === 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:3333)
|
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
|
||||||
|
|
||||||
# === KNOWLEDGE BASE (RAG) ===
|
# === KNOWLEDGE BASE (RAG) ===
|
||||||
knowledge:
|
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
|
||||||
|
|
||||||
# === MESH DATA SOURCES ===
|
# === MESH DATA SOURCES ===
|
||||||
# Connect to Meshview and/or MeshMonitor instances for live mesh
|
# Connect to Meshview and/or MeshMonitor instances for live mesh
|
||||||
# network analysis. Supports multiple sources. Configure via TUI
|
# network analysis. Supports multiple sources. Configure via TUI
|
||||||
# with meshai --config (Mesh Sources menu).
|
# with meshai --config (Mesh Sources menu).
|
||||||
#
|
#
|
||||||
# mesh_sources:
|
# mesh_sources:
|
||||||
# - name: "my-meshview"
|
# - name: "my-meshview"
|
||||||
# type: meshview
|
# type: meshview
|
||||||
# url: "https://meshview.example.com"
|
# url: "https://meshview.example.com"
|
||||||
# refresh_interval: 300
|
# refresh_interval: 300
|
||||||
# enabled: true
|
# enabled: true
|
||||||
#
|
#
|
||||||
# - name: "my-meshmonitor"
|
# - name: "my-meshmonitor"
|
||||||
# type: meshmonitor
|
# type: meshmonitor
|
||||||
# url: "http://192.168.1.100:3333"
|
# url: "http://192.168.1.100:3333"
|
||||||
# api_token: "${MM_API_TOKEN}"
|
# api_token: "${MM_API_TOKEN}"
|
||||||
# refresh_interval: 300
|
# refresh_interval: 300
|
||||||
# enabled: true
|
# enabled: true
|
||||||
mesh_sources: []
|
mesh_sources: []
|
||||||
|
|
||||||
# === MESH INTELLIGENCE ===
|
# === MESH INTELLIGENCE ===
|
||||||
# Geographic clustering and health scoring for mesh analysis.
|
# Geographic clustering and health scoring for mesh analysis.
|
||||||
|
|
@ -123,9 +123,3 @@ mesh_intelligence:
|
||||||
battery_warning_percent: 20
|
battery_warning_percent: 20
|
||||||
infra_overrides: []
|
infra_overrides: []
|
||||||
region_labels: {}
|
region_labels: {}
|
||||||
|
|
||||||
# === WEB DASHBOARD ===
|
|
||||||
dashboard:
|
|
||||||
enabled: true
|
|
||||||
port: 8080
|
|
||||||
host: "0.0.0.0"
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>MeshAI Dashboard</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"name": "meshai-dashboard",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.3.0",
|
|
||||||
"react-dom": "^18.3.0",
|
|
||||||
"react-router-dom": "^6.23.0",
|
|
||||||
"recharts": "^2.12.0",
|
|
||||||
"lucide-react": "^0.383.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.3.0",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
|
||||||
"typescript": "^5.4.0",
|
|
||||||
"vite": "^5.4.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"autoprefixer": "^10.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
|
||||||
import Layout from './components/Layout'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
|
||||||
import Mesh from './pages/Mesh'
|
|
||||||
import Environment from './pages/Environment'
|
|
||||||
import Config from './pages/Config'
|
|
||||||
import Alerts from './pages/Alerts'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/mesh" element={<Mesh />} />
|
|
||||||
<Route path="/environment" element={<Environment />} />
|
|
||||||
<Route path="/config" element={<Config />} />
|
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Radio,
|
|
||||||
Cloud,
|
|
||||||
Settings,
|
|
||||||
Bell,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
|
||||||
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
|
||||||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
|
||||||
{ path: '/config', label: 'Config', icon: Settings },
|
|
||||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
|
||||||
const days = Math.floor(seconds / 86400)
|
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60)
|
|
||||||
|
|
||||||
if (days > 0) return `${days}d ${hours}h`
|
|
||||||
if (hours > 0) return `${hours}h ${mins}m`
|
|
||||||
return `${mins}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPageTitle(pathname: string): string {
|
|
||||||
const item = navItems.find((i) => i.path === pathname)
|
|
||||||
return item?.label || 'Dashboard'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
|
||||||
const location = useLocation()
|
|
||||||
const { connected } = useWebSocket()
|
|
||||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
|
||||||
const [currentTime, setCurrentTime] = useState(new Date())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus().then(setStatus).catch(console.error)
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchStatus().then(setStatus).catch(console.error)
|
|
||||||
}, 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => setCurrentTime(new Date()), 1000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const timeStr = currentTime.toLocaleTimeString('en-US', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen overflow-hidden bg-bg text-slate-200">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="p-5 border-b border-border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center text-white font-bold text-xl">
|
|
||||||
M
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-lg">MeshAI</div>
|
|
||||||
<div className="text-xs text-slate-500 font-mono">
|
|
||||||
v{status?.version || '...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 py-4">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = location.pathname === item.path
|
|
||||||
const Icon = item.icon
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors relative ${
|
|
||||||
isActive
|
|
||||||
? 'text-blue-400 bg-blue-500/10'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-bg-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive && (
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-500" />
|
|
||||||
)}
|
|
||||||
<Icon size={18} />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Connection status */}
|
|
||||||
<div className="p-5 border-t border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div
|
|
||||||
className={`w-2 h-2 rounded-full ${
|
|
||||||
status?.connected ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{status?.connected ? 'Connected' : 'Disconnected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 font-mono truncate">
|
|
||||||
{status?.connection_type?.toUpperCase()}: {status?.connection_target}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
|
||||||
Uptime: {status ? formatUptime(status.uptime_seconds) : '...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
{getPageTitle(location.pathname)}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{/* Live indicator */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`w-2 h-2 rounded-full ${
|
|
||||||
connected ? 'bg-green-500 animate-pulse-slow' : 'bg-slate-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{connected ? 'Live' : 'Offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Clock */}
|
|
||||||
<div className="text-sm font-mono text-slate-400">
|
|
||||||
{timeStr} MT
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Page content */}
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
||||||
import type { MeshHealth, Alert } from '@/lib/api'
|
|
||||||
|
|
||||||
interface WebSocketMessage {
|
|
||||||
type: string
|
|
||||||
data: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseWebSocketReturn {
|
|
||||||
connected: boolean
|
|
||||||
lastHealth: MeshHealth | null
|
|
||||||
lastAlert: Alert | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebSocket(): UseWebSocketReturn {
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
|
||||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
|
||||||
const reconnectDelayRef = useRef(1000)
|
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/live`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ws = new WebSocket(wsUrl)
|
|
||||||
wsRef.current = ws
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
setConnected(true)
|
|
||||||
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message: WebSocketMessage = JSON.parse(event.data)
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case 'health_update':
|
|
||||||
setLastHealth(message.data as MeshHealth)
|
|
||||||
break
|
|
||||||
case 'alert_fired':
|
|
||||||
setLastAlert(message.data as Alert)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse WebSocket message:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
setConnected(false)
|
|
||||||
wsRef.current = null
|
|
||||||
|
|
||||||
// Schedule reconnect with exponential backoff
|
|
||||||
const delay = Math.min(reconnectDelayRef.current, 30000)
|
|
||||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
reconnectDelayRef.current = Math.min(delay * 2, 30000)
|
|
||||||
connect()
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keepalive ping every 30 seconds
|
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send('ping')
|
|
||||||
}
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
clearInterval(pingInterval)
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create WebSocket:', e)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
connect()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [connect])
|
|
||||||
|
|
||||||
return { connected, lastHealth, lastAlert }
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: #0a0e17;
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #0a0e17;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #2d3a4d;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #3b4a5d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Data values use JetBrains Mono */
|
|
||||||
.font-mono {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pulsing animation for live indicator */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse-slow {
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
// API types matching actual backend responses
|
|
||||||
|
|
||||||
export interface SystemStatus {
|
|
||||||
version: string
|
|
||||||
uptime_seconds: number
|
|
||||||
bot_name: string
|
|
||||||
connection_type: string
|
|
||||||
connection_target: string
|
|
||||||
connected: boolean
|
|
||||||
node_count: number
|
|
||||||
source_count: number
|
|
||||||
env_feeds_enabled: boolean
|
|
||||||
dashboard_port: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeshHealth {
|
|
||||||
score: number
|
|
||||||
tier: string
|
|
||||||
pillars: {
|
|
||||||
infrastructure: number
|
|
||||||
utilization: number
|
|
||||||
behavior: number
|
|
||||||
power: number
|
|
||||||
}
|
|
||||||
infra_online: number
|
|
||||||
infra_total: number
|
|
||||||
util_percent: number
|
|
||||||
flagged_nodes: number
|
|
||||||
battery_warnings: number
|
|
||||||
total_nodes: number
|
|
||||||
total_regions: number
|
|
||||||
unlocated_count: number
|
|
||||||
last_computed: string
|
|
||||||
recommendations: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeInfo {
|
|
||||||
node_num: number
|
|
||||||
node_id_hex: string
|
|
||||||
short_name: string
|
|
||||||
long_name: string
|
|
||||||
role: string
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
last_heard: string | null
|
|
||||||
battery_level: number | null
|
|
||||||
voltage: number | null
|
|
||||||
snr: number | null
|
|
||||||
firmware: string
|
|
||||||
hardware: string
|
|
||||||
uptime: number | null
|
|
||||||
sources: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EdgeInfo {
|
|
||||||
from_node: number
|
|
||||||
to_node: number
|
|
||||||
snr: number
|
|
||||||
quality: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SourceHealth {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
url: string
|
|
||||||
is_loaded: boolean
|
|
||||||
last_error: string | null
|
|
||||||
consecutive_errors: number
|
|
||||||
response_time_ms: number | null
|
|
||||||
tick_count: number
|
|
||||||
node_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alert {
|
|
||||||
type: string
|
|
||||||
severity: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
scope_type?: string
|
|
||||||
scope_value?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvStatus {
|
|
||||||
enabled: boolean
|
|
||||||
feeds: unknown[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvEvent {
|
|
||||||
type: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
// API fetch helpers
|
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchStatus(): Promise<SystemStatus> {
|
|
||||||
return fetchJson<SystemStatus>('/api/status')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchHealth(): Promise<MeshHealth> {
|
|
||||||
return fetchJson<MeshHealth>('/api/health')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
|
||||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
|
||||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
|
||||||
return fetchJson<SourceHealth[]>('/api/sources')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
|
||||||
const url = section ? `/api/config/${section}` : '/api/config'
|
|
||||||
return fetchJson(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateConfig(
|
|
||||||
section: string,
|
|
||||||
data: unknown
|
|
||||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
|
||||||
const response = await fetch(`/api/config/${section}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAlerts(): Promise<Alert[]> {
|
|
||||||
return fetchJson<Alert[]>('/api/alerts/active')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
|
||||||
return fetchJson<EnvStatus>('/api/env/status')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
|
||||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRegions(): Promise<unknown[]> {
|
|
||||||
return fetchJson<unknown[]>('/api/regions')
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
|
||||||
import App from './App'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Bell } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Alerts() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
|
||||||
<Bell size={32} className="text-slate-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Alerts</h2>
|
|
||||||
<p className="text-slate-500 max-w-md">
|
|
||||||
Alert history and subscriptions coming in Phase 11
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Settings } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Config() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
|
||||||
<Settings size={32} className="text-slate-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Configuration</h2>
|
|
||||||
<p className="text-slate-500 max-w-md">
|
|
||||||
Configuration management coming in Phase 5
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
fetchHealth,
|
|
||||||
fetchSources,
|
|
||||||
fetchAlerts,
|
|
||||||
fetchEnvStatus,
|
|
||||||
type MeshHealth,
|
|
||||||
type SourceHealth,
|
|
||||||
type Alert,
|
|
||||||
type EnvStatus,
|
|
||||||
} from '@/lib/api'
|
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
AlertCircle,
|
|
||||||
Info,
|
|
||||||
CheckCircle,
|
|
||||||
Radio,
|
|
||||||
Cpu,
|
|
||||||
Activity,
|
|
||||||
MapPin,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
function HealthGauge({ health }: { health: MeshHealth }) {
|
|
||||||
const score = health.score
|
|
||||||
const tier = health.tier
|
|
||||||
|
|
||||||
// Color based on score
|
|
||||||
const getColor = (s: number) => {
|
|
||||||
if (s >= 80) return '#22c55e'
|
|
||||||
if (s >= 60) return '#f59e0b'
|
|
||||||
return '#ef4444'
|
|
||||||
}
|
|
||||||
|
|
||||||
const color = getColor(score)
|
|
||||||
const circumference = 2 * Math.PI * 45
|
|
||||||
const progress = (score / 100) * circumference
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<svg width="140" height="140" viewBox="0 0 100 100">
|
|
||||||
{/* Background circle */}
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="45"
|
|
||||||
fill="none"
|
|
||||||
stroke="#1e2a3a"
|
|
||||||
strokeWidth="8"
|
|
||||||
/>
|
|
||||||
{/* Progress arc */}
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="45"
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={circumference - progress}
|
|
||||||
transform="rotate(-90 50 50)"
|
|
||||||
className="transition-all duration-500"
|
|
||||||
/>
|
|
||||||
{/* Score text */}
|
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="46"
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-slate-100 font-mono text-2xl font-bold"
|
|
||||||
style={{ fontSize: '24px' }}
|
|
||||||
>
|
|
||||||
{score.toFixed(1)}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="62"
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-slate-400 text-xs"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
>
|
|
||||||
{tier}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PillarBar({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
}) {
|
|
||||||
const getColor = (v: number) => {
|
|
||||||
if (v >= 80) return 'bg-green-500'
|
|
||||||
if (v >= 60) return 'bg-amber-500'
|
|
||||||
return 'bg-red-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-24 text-xs text-slate-400 truncate">{label}</div>
|
|
||||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full ${getColor(value)} transition-all duration-300`}
|
|
||||||
style={{ width: `${value}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 text-right text-xs font-mono text-slate-300">
|
|
||||||
{value.toFixed(1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertItem({ alert }: { alert: Alert }) {
|
|
||||||
const getSeverityStyles = (severity: string) => {
|
|
||||||
switch (severity.toLowerCase()) {
|
|
||||||
case 'critical':
|
|
||||||
case 'emergency':
|
|
||||||
return {
|
|
||||||
bg: 'bg-red-500/10',
|
|
||||||
border: 'border-red-500',
|
|
||||||
icon: AlertCircle,
|
|
||||||
iconColor: 'text-red-500',
|
|
||||||
}
|
|
||||||
case 'warning':
|
|
||||||
return {
|
|
||||||
bg: 'bg-amber-500/10',
|
|
||||||
border: 'border-amber-500',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
iconColor: 'text-amber-500',
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
bg: 'bg-green-500/10',
|
|
||||||
border: 'border-green-500',
|
|
||||||
icon: Info,
|
|
||||||
iconColor: 'text-green-500',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = getSeverityStyles(alert.severity)
|
|
||||||
const Icon = styles.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
|
|
||||||
>
|
|
||||||
<Icon size={16} className={styles.iconColor} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
|
||||||
{alert.timestamp || 'Just now'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SourceCard({ source }: { source: SourceHealth }) {
|
|
||||||
const getStatusColor = () => {
|
|
||||||
if (!source.is_loaded) return 'bg-red-500'
|
|
||||||
if (source.last_error) return 'bg-amber-500'
|
|
||||||
return 'bg-green-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-hover">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm text-slate-200 truncate">{source.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{source.node_count} nodes • {source.type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
subvalue,
|
|
||||||
}: {
|
|
||||||
icon: typeof Radio
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
subvalue?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400 mb-2">
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-xs">{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xl text-slate-100">{value}</div>
|
|
||||||
{subvalue && (
|
|
||||||
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [health, setHealth] = useState<MeshHealth | null>(null)
|
|
||||||
const [sources, setSources] = useState<SourceHealth[]>([])
|
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
|
||||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { lastHealth } = useWebSocket()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([
|
|
||||||
fetchHealth(),
|
|
||||||
fetchSources(),
|
|
||||||
fetchAlerts(),
|
|
||||||
fetchEnvStatus(),
|
|
||||||
])
|
|
||||||
.then(([h, src, a, e]) => {
|
|
||||||
setHealth(h)
|
|
||||||
setSources(src)
|
|
||||||
setAlerts(a)
|
|
||||||
setEnvStatus(e)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err.message)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Update health from WebSocket
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastHealth) {
|
|
||||||
setHealth(lastHealth)
|
|
||||||
}
|
|
||||||
}, [lastHealth])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-slate-400">Loading...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-red-400">Error: {error}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Mesh Health */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
|
|
||||||
{health && (
|
|
||||||
<>
|
|
||||||
<HealthGauge health={health} />
|
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
<PillarBar label="Infrastructure" value={health.pillars.infrastructure} />
|
|
||||||
<PillarBar label="Utilization" value={health.pillars.utilization} />
|
|
||||||
<PillarBar label="Behavior" value={health.pillars.behavior} />
|
|
||||||
<PillarBar label="Power" value={health.pillars.power} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts + Stats */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{/* Active Alerts */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
|
||||||
Active Alerts
|
|
||||||
</h2>
|
|
||||||
{alerts.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.map((alert, i) => (
|
|
||||||
<AlertItem key={i} alert={alert} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-slate-500 py-4">
|
|
||||||
<CheckCircle size={16} className="text-green-500" />
|
|
||||||
<span>No active alerts</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
icon={Radio}
|
|
||||||
label="Nodes Online"
|
|
||||||
value={health?.total_nodes || 0}
|
|
||||||
subvalue={`${health?.unlocated_count || 0} unlocated`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={Cpu}
|
|
||||||
label="Infrastructure"
|
|
||||||
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`}
|
|
||||||
subvalue={
|
|
||||||
health?.infra_online === health?.infra_total
|
|
||||||
? 'All online'
|
|
||||||
: 'Some offline'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={Activity}
|
|
||||||
label="Utilization"
|
|
||||||
value={`${health?.util_percent?.toFixed(1) || 0}%`}
|
|
||||||
subvalue={`${health?.flagged_nodes || 0} flagged`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={MapPin}
|
|
||||||
label="Regions"
|
|
||||||
value={health?.total_regions || 0}
|
|
||||||
subvalue={`${health?.battery_warnings || 0} battery warnings`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mesh Sources */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
|
||||||
Mesh Sources ({sources.length})
|
|
||||||
</h2>
|
|
||||||
{sources.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sources.map((source, i) => (
|
|
||||||
<SourceCard key={i} source={source} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-500 py-4">No sources configured</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Environmental Feeds */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
|
||||||
Environmental Feeds
|
|
||||||
</h2>
|
|
||||||
{envStatus?.enabled ? (
|
|
||||||
<div className="text-slate-400">
|
|
||||||
{envStatus.feeds.length} feeds active
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-500">
|
|
||||||
<p>Environmental feeds not enabled.</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
Enable in Config → Mesh Intelligence
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HF Propagation placeholder */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
|
||||||
HF Propagation
|
|
||||||
</h2>
|
|
||||||
<div className="text-slate-500">
|
|
||||||
<p>Space weather data not enabled.</p>
|
|
||||||
<p className="text-xs mt-2">Coming in Phase 1</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Cloud } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Environment() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
|
||||||
<Cloud size={32} className="text-slate-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Environment</h2>
|
|
||||||
<p className="text-slate-500 max-w-md">
|
|
||||||
Environmental feeds and space weather detail coming soon
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Radio } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Mesh() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
|
||||||
<Radio size={32} className="text-slate-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Mesh</h2>
|
|
||||||
<p className="text-slate-500 max-w-md">
|
|
||||||
Topology graph and geographic map coming in Phase 6
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
1
dashboard-frontend/src/vite-env.d.ts
vendored
1
dashboard-frontend/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import type { Config } from 'tailwindcss'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
bg: {
|
|
||||||
DEFAULT: '#0a0e17',
|
|
||||||
card: '#111827',
|
|
||||||
hover: '#1a2332',
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
DEFAULT: '#1e2a3a',
|
|
||||||
light: '#2d3a4d',
|
|
||||||
},
|
|
||||||
accent: '#3b82f6',
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
mono: ['JetBrains Mono', 'monospace'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
} satisfies Config
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: '../meshai/dashboard/static',
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://localhost:8080',
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -35,8 +35,6 @@ services:
|
||||||
ports:
|
ports:
|
||||||
# Web-based config interface (ttyd)
|
# Web-based config interface (ttyd)
|
||||||
- "7682:7682"
|
- "7682:7682"
|
||||||
# Dashboard API
|
|
||||||
- "8080:8080"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent data (database, config)
|
# Persistent data (database, config)
|
||||||
|
|
|
||||||
|
|
@ -257,16 +257,6 @@ class MeshIntelligenceConfig:
|
||||||
alert_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig)
|
alert_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DashboardConfig:
|
|
||||||
"""Web dashboard settings."""
|
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
port: int = 8080
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""Main configuration container."""
|
"""Main configuration container."""
|
||||||
|
|
@ -284,7 +274,6 @@ class Config:
|
||||||
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
||||||
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
|
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
|
||||||
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
|
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
|
||||||
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
|
||||||
|
|
||||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Dashboard package for MeshAI web interface."""
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Dashboard API routes package."""
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""Alert API routes."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
router = APIRouter(tags=["alerts"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/alerts/active")
|
|
||||||
async def get_active_alerts(request: Request):
|
|
||||||
"""Get currently active alerts."""
|
|
||||||
alert_engine = request.app.state.alert_engine
|
|
||||||
|
|
||||||
if not alert_engine:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Get recent alerts from alert engine if it has internal state
|
|
||||||
alerts = []
|
|
||||||
|
|
||||||
# Check for AlertState or similar if available
|
|
||||||
if hasattr(alert_engine, "get_active_alerts"):
|
|
||||||
try:
|
|
||||||
raw_alerts = alert_engine.get_active_alerts()
|
|
||||||
for alert in raw_alerts:
|
|
||||||
alerts.append({
|
|
||||||
"type": alert.get("type", "unknown"),
|
|
||||||
"severity": alert.get("severity", "info"),
|
|
||||||
"message": alert.get("message", ""),
|
|
||||||
"timestamp": alert.get("timestamp"),
|
|
||||||
"scope_type": alert.get("scope_type"),
|
|
||||||
"scope_value": alert.get("scope_value"),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif hasattr(alert_engine, "_recent_alerts"):
|
|
||||||
try:
|
|
||||||
for alert in alert_engine._recent_alerts:
|
|
||||||
alerts.append({
|
|
||||||
"type": alert.get("type", "unknown"),
|
|
||||||
"severity": alert.get("severity", "info"),
|
|
||||||
"message": alert.get("message", ""),
|
|
||||||
"timestamp": alert.get("timestamp"),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/alerts/history")
|
|
||||||
async def get_alert_history(
|
|
||||||
request: Request,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
):
|
|
||||||
"""Get historical alerts with pagination."""
|
|
||||||
# Historical alert data would come from SQLite
|
|
||||||
# For now, return empty list
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions")
|
|
||||||
async def get_subscriptions(request: Request):
|
|
||||||
"""Get all alert subscriptions."""
|
|
||||||
subscription_manager = request.app.state.subscription_manager
|
|
||||||
|
|
||||||
if not subscription_manager:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
subs = subscription_manager.get_all_subs()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": sub["id"],
|
|
||||||
"user_id": sub["user_id"],
|
|
||||||
"sub_type": sub["sub_type"],
|
|
||||||
"schedule_time": sub.get("schedule_time"),
|
|
||||||
"schedule_day": sub.get("schedule_day"),
|
|
||||||
"scope_type": sub.get("scope_type", "mesh"),
|
|
||||||
"scope_value": sub.get("scope_value"),
|
|
||||||
"enabled": sub.get("enabled", 1) == 1,
|
|
||||||
}
|
|
||||||
for sub in subs
|
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""Configuration API routes."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
|
|
||||||
from meshai.config import (
|
|
||||||
Config,
|
|
||||||
_dataclass_to_dict,
|
|
||||||
_dict_to_dataclass,
|
|
||||||
load_config,
|
|
||||||
save_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["config"])
|
|
||||||
|
|
||||||
# Sections that require restart when changed
|
|
||||||
RESTART_REQUIRED_SECTIONS = {
|
|
||||||
"connection",
|
|
||||||
"llm",
|
|
||||||
"mesh_sources",
|
|
||||||
"meshmonitor",
|
|
||||||
"dashboard",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Valid config section names
|
|
||||||
VALID_SECTIONS = {
|
|
||||||
"bot",
|
|
||||||
"connection",
|
|
||||||
"response",
|
|
||||||
"history",
|
|
||||||
"memory",
|
|
||||||
"context",
|
|
||||||
"commands",
|
|
||||||
"llm",
|
|
||||||
"weather",
|
|
||||||
"meshmonitor",
|
|
||||||
"knowledge",
|
|
||||||
"mesh_sources",
|
|
||||||
"mesh_intelligence",
|
|
||||||
"dashboard",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
|
||||||
async def get_full_config(request: Request):
|
|
||||||
"""Get full configuration."""
|
|
||||||
config = request.app.state.config
|
|
||||||
return _dataclass_to_dict(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/{section}")
|
|
||||||
async def get_config_section(section: str, request: Request):
|
|
||||||
"""Get a specific configuration section."""
|
|
||||||
if section not in VALID_SECTIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
config = request.app.state.config
|
|
||||||
|
|
||||||
if not hasattr(config, section):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Section '{section}' not found")
|
|
||||||
|
|
||||||
section_data = getattr(config, section)
|
|
||||||
|
|
||||||
# Handle list types (mesh_sources)
|
|
||||||
if isinstance(section_data, list):
|
|
||||||
return [
|
|
||||||
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
|
||||||
for item in section_data
|
|
||||||
]
|
|
||||||
|
|
||||||
# Handle dataclass types
|
|
||||||
if hasattr(section_data, "__dataclass_fields__"):
|
|
||||||
return _dataclass_to_dict(section_data)
|
|
||||||
|
|
||||||
return section_data
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/config/{section}")
|
|
||||||
async def update_config_section(section: str, request: Request):
|
|
||||||
"""Update a configuration section."""
|
|
||||||
if section not in VALID_SECTIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
config_path = request.app.state.config_path
|
|
||||||
if not config_path:
|
|
||||||
raise HTTPException(status_code=500, detail="Config path not set")
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Load fresh config from file to avoid conflicts
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
# Get the section's dataclass type
|
|
||||||
field_info = Config.__dataclass_fields__.get(section)
|
|
||||||
if not field_info:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Section '{section}' not found")
|
|
||||||
|
|
||||||
field_type = field_info.type
|
|
||||||
|
|
||||||
# Handle list types (mesh_sources)
|
|
||||||
if section == "mesh_sources":
|
|
||||||
from meshai.config import MeshSourceConfig
|
|
||||||
new_value = [
|
|
||||||
_dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item
|
|
||||||
for item in body
|
|
||||||
]
|
|
||||||
# Handle dataclass types
|
|
||||||
elif hasattr(field_type, "__dataclass_fields__"):
|
|
||||||
new_value = _dict_to_dataclass(field_type, body)
|
|
||||||
else:
|
|
||||||
new_value = body
|
|
||||||
|
|
||||||
# Set the section on config
|
|
||||||
setattr(config, section, new_value)
|
|
||||||
|
|
||||||
# Save config to file
|
|
||||||
save_config(config, config_path)
|
|
||||||
|
|
||||||
# Determine if restart is required
|
|
||||||
restart_required = section in RESTART_REQUIRED_SECTIONS
|
|
||||||
|
|
||||||
# Update live config if restart not required
|
|
||||||
if not restart_required:
|
|
||||||
request.app.state.config = config
|
|
||||||
|
|
||||||
logger.info(f"Config section '{section}' updated, restart_required={restart_required}")
|
|
||||||
|
|
||||||
return {"saved": True, "restart_required": restart_required}
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=422, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Config update error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/test-llm")
|
|
||||||
async def test_llm_connection(request: Request):
|
|
||||||
"""Test LLM backend connection."""
|
|
||||||
config = request.app.state.config
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create LLM backend based on config
|
|
||||||
api_key = config.resolve_api_key()
|
|
||||||
if not api_key:
|
|
||||||
return {"success": False, "error": "No API key configured"}
|
|
||||||
|
|
||||||
backend_name = config.llm.backend.lower()
|
|
||||||
|
|
||||||
if backend_name == "openai":
|
|
||||||
from meshai.backends import OpenAIBackend
|
|
||||||
backend = OpenAIBackend(config.llm, api_key, 0, 0)
|
|
||||||
elif backend_name == "anthropic":
|
|
||||||
from meshai.backends import AnthropicBackend
|
|
||||||
backend = AnthropicBackend(config.llm, api_key, 0, 0)
|
|
||||||
elif backend_name == "google":
|
|
||||||
from meshai.backends import GoogleBackend
|
|
||||||
backend = GoogleBackend(config.llm, api_key, 0, 0)
|
|
||||||
else:
|
|
||||||
return {"success": False, "error": f"Unknown backend: {backend_name}"}
|
|
||||||
|
|
||||||
# Send test prompt
|
|
||||||
response = await backend.generate("Reply with 'OK' if you can read this.", [])
|
|
||||||
await backend.close()
|
|
||||||
|
|
||||||
return {"success": True, "response": response}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"LLM test error: {e}")
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""Environmental data API routes (Phase 1 placeholder)."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
|
||||||
async def get_env_status(request: Request):
|
|
||||||
"""Get environmental feeds status."""
|
|
||||||
env_store = request.app.state.env_store
|
|
||||||
|
|
||||||
if not env_store:
|
|
||||||
return {"enabled": False, "feeds": []}
|
|
||||||
|
|
||||||
# Will be populated in Phase 1 when env_store exists
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"feeds": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/active")
|
|
||||||
async def get_active_env(request: Request):
|
|
||||||
"""Get active environmental conditions."""
|
|
||||||
env_store = request.app.state.env_store
|
|
||||||
|
|
||||||
if not env_store:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Will be populated in Phase 1
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
|
||||||
async def get_swpc_data(request: Request):
|
|
||||||
"""Get SWPC space weather data."""
|
|
||||||
env_store = request.app.state.env_store
|
|
||||||
|
|
||||||
if not env_store:
|
|
||||||
return {"enabled": False}
|
|
||||||
|
|
||||||
# Will be populated in Phase 1
|
|
||||||
return {"enabled": False}
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
"""Mesh health and node API routes."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
|
|
||||||
router = APIRouter(tags=["mesh"])
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_health_score(score) -> dict:
|
|
||||||
"""Serialize a HealthScore object."""
|
|
||||||
return {
|
|
||||||
"composite": round(score.composite, 1),
|
|
||||||
"tier": score.tier,
|
|
||||||
"infrastructure": round(score.infrastructure, 1),
|
|
||||||
"utilization": round(score.utilization, 1),
|
|
||||||
"behavior": round(score.behavior, 1),
|
|
||||||
"power": round(score.power, 1),
|
|
||||||
"infra_online": score.infra_online,
|
|
||||||
"infra_total": score.infra_total,
|
|
||||||
"util_percent": round(score.util_percent, 1),
|
|
||||||
"flagged_nodes": score.flagged_nodes,
|
|
||||||
"battery_warnings": score.battery_warnings,
|
|
||||||
"solar_index": round(score.solar_index, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_region(region) -> dict:
|
|
||||||
"""Serialize a RegionHealth object."""
|
|
||||||
return {
|
|
||||||
"name": region.name,
|
|
||||||
"center_lat": region.center_lat,
|
|
||||||
"center_lon": region.center_lon,
|
|
||||||
"node_count": len(region.node_ids),
|
|
||||||
"locality_count": len(region.localities),
|
|
||||||
"score": _serialize_health_score(region.score),
|
|
||||||
"node_ids": region.node_ids,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp(ts: Optional[float]) -> Optional[str]:
|
|
||||||
"""Format a Unix timestamp as ISO string."""
|
|
||||||
if not ts or ts <= 0:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(ts).isoformat()
|
|
||||||
except (ValueError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def get_health(request: Request):
|
|
||||||
"""Get mesh health data."""
|
|
||||||
health_engine = request.app.state.health_engine
|
|
||||||
|
|
||||||
if not health_engine or not health_engine.mesh_health:
|
|
||||||
return {
|
|
||||||
"score": 0,
|
|
||||||
"tier": "Unknown",
|
|
||||||
"message": "Health engine not ready",
|
|
||||||
}
|
|
||||||
|
|
||||||
health = health_engine.mesh_health
|
|
||||||
score = health.score
|
|
||||||
|
|
||||||
return {
|
|
||||||
"score": round(score.composite, 1),
|
|
||||||
"tier": score.tier,
|
|
||||||
"pillars": {
|
|
||||||
"infrastructure": round(score.infrastructure, 1),
|
|
||||||
"utilization": round(score.utilization, 1),
|
|
||||||
"behavior": round(score.behavior, 1),
|
|
||||||
"power": round(score.power, 1),
|
|
||||||
},
|
|
||||||
"infra_online": score.infra_online,
|
|
||||||
"infra_total": score.infra_total,
|
|
||||||
"util_percent": round(score.util_percent, 1),
|
|
||||||
"flagged_nodes": score.flagged_nodes,
|
|
||||||
"battery_warnings": score.battery_warnings,
|
|
||||||
"total_nodes": health.total_nodes,
|
|
||||||
"total_regions": health.total_regions,
|
|
||||||
"unlocated_count": len(health.unlocated_nodes),
|
|
||||||
"last_computed": _format_timestamp(health.last_computed),
|
|
||||||
"recommendations": [], # TODO: Add recommendations
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nodes")
|
|
||||||
async def get_nodes(request: Request):
|
|
||||||
"""Get all nodes."""
|
|
||||||
data_store = request.app.state.data_store
|
|
||||||
health_engine = request.app.state.health_engine
|
|
||||||
|
|
||||||
if not data_store:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_nodes = data_store.get_all_nodes()
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
for node in raw_nodes:
|
|
||||||
# Extract node_num from various formats
|
|
||||||
node_num = node.get("nodeNum") or node.get("num") or node.get("node_num")
|
|
||||||
if node_num is None:
|
|
||||||
node_id = node.get("node_id") or node.get("id")
|
|
||||||
if node_id and isinstance(node_id, str):
|
|
||||||
try:
|
|
||||||
node_num = int(node_id.lstrip("!"), 16)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if node_num is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get health data if available
|
|
||||||
health_data = {}
|
|
||||||
if health_engine and health_engine.mesh_health:
|
|
||||||
node_health = health_engine.mesh_health.nodes.get(str(node_num))
|
|
||||||
if node_health:
|
|
||||||
health_data = {
|
|
||||||
"region": node_health.region,
|
|
||||||
"locality": node_health.locality,
|
|
||||||
"is_infrastructure": node_health.is_infrastructure,
|
|
||||||
"is_online": node_health.is_online,
|
|
||||||
"packet_count_24h": node_health.packet_count_24h,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build node dict
|
|
||||||
node_dict = {
|
|
||||||
"node_num": node_num,
|
|
||||||
"node_id_hex": f"!{node_num:08x}",
|
|
||||||
"short_name": node.get("shortName") or node.get("short_name") or "",
|
|
||||||
"long_name": node.get("longName") or node.get("long_name") or "",
|
|
||||||
"role": node.get("role") or "",
|
|
||||||
"latitude": node.get("latitude"),
|
|
||||||
"longitude": node.get("longitude"),
|
|
||||||
"last_heard": _format_timestamp(node.get("last_heard")),
|
|
||||||
"battery_level": node.get("battery_level") or node.get("batteryLevel"),
|
|
||||||
"voltage": node.get("voltage"),
|
|
||||||
"snr": node.get("snr"),
|
|
||||||
"firmware": node.get("firmware_version") or node.get("firmwareVersion") or "",
|
|
||||||
"hardware": node.get("hw_model") or node.get("hwModel") or "",
|
|
||||||
"uptime": node.get("uptime_seconds") or node.get("uptimeSeconds"),
|
|
||||||
"sources": node.get("_sources", []),
|
|
||||||
**health_data,
|
|
||||||
}
|
|
||||||
nodes.append(node_dict)
|
|
||||||
|
|
||||||
return nodes
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nodes/{node_num}")
|
|
||||||
async def get_node_detail(node_num: int, request: Request):
|
|
||||||
"""Get detailed info for a specific node."""
|
|
||||||
data_store = request.app.state.data_store
|
|
||||||
health_engine = request.app.state.health_engine
|
|
||||||
|
|
||||||
if not data_store:
|
|
||||||
raise HTTPException(status_code=404, detail="Data store not available")
|
|
||||||
|
|
||||||
# Find the node
|
|
||||||
try:
|
|
||||||
raw_nodes = data_store.get_all_nodes()
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to fetch nodes")
|
|
||||||
|
|
||||||
target_node = None
|
|
||||||
for node in raw_nodes:
|
|
||||||
n_num = node.get("nodeNum") or node.get("num") or node.get("node_num")
|
|
||||||
if n_num is None:
|
|
||||||
node_id = node.get("node_id") or node.get("id")
|
|
||||||
if node_id and isinstance(node_id, str):
|
|
||||||
try:
|
|
||||||
n_num = int(node_id.lstrip("!"), 16)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if n_num == node_num:
|
|
||||||
target_node = node
|
|
||||||
break
|
|
||||||
|
|
||||||
if not target_node:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Node {node_num} not found")
|
|
||||||
|
|
||||||
# Get health data
|
|
||||||
health_data = {}
|
|
||||||
if health_engine and health_engine.mesh_health:
|
|
||||||
node_health = health_engine.mesh_health.nodes.get(str(node_num))
|
|
||||||
if node_health:
|
|
||||||
health_data = {
|
|
||||||
"region": node_health.region,
|
|
||||||
"locality": node_health.locality,
|
|
||||||
"is_infrastructure": node_health.is_infrastructure,
|
|
||||||
"is_online": node_health.is_online,
|
|
||||||
"packet_count_24h": node_health.packet_count_24h,
|
|
||||||
"text_packet_count_24h": node_health.text_packet_count_24h,
|
|
||||||
"non_text_packets": node_health.non_text_packets,
|
|
||||||
"has_solar": node_health.has_solar,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get neighbors from edges
|
|
||||||
neighbors = []
|
|
||||||
try:
|
|
||||||
edges = data_store.get_all_edges()
|
|
||||||
for edge in edges:
|
|
||||||
from_num = edge.get("from_node") or edge.get("from")
|
|
||||||
to_num = edge.get("to_node") or edge.get("to")
|
|
||||||
|
|
||||||
if from_num == node_num:
|
|
||||||
neighbors.append({
|
|
||||||
"node_num": to_num,
|
|
||||||
"snr": edge.get("snr"),
|
|
||||||
})
|
|
||||||
elif to_num == node_num:
|
|
||||||
neighbors.append({
|
|
||||||
"node_num": from_num,
|
|
||||||
"snr": edge.get("snr"),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"node_num": node_num,
|
|
||||||
"node_id_hex": f"!{node_num:08x}",
|
|
||||||
"short_name": target_node.get("shortName") or target_node.get("short_name") or "",
|
|
||||||
"long_name": target_node.get("longName") or target_node.get("long_name") or "",
|
|
||||||
"role": target_node.get("role") or "",
|
|
||||||
"latitude": target_node.get("latitude"),
|
|
||||||
"longitude": target_node.get("longitude"),
|
|
||||||
"last_heard": _format_timestamp(target_node.get("last_heard")),
|
|
||||||
"battery_level": target_node.get("battery_level") or target_node.get("batteryLevel"),
|
|
||||||
"voltage": target_node.get("voltage"),
|
|
||||||
"snr": target_node.get("snr"),
|
|
||||||
"firmware": target_node.get("firmware_version") or target_node.get("firmwareVersion") or "",
|
|
||||||
"hardware": target_node.get("hw_model") or target_node.get("hwModel") or "",
|
|
||||||
"uptime": target_node.get("uptime_seconds") or target_node.get("uptimeSeconds"),
|
|
||||||
"sources": target_node.get("_sources", []),
|
|
||||||
"neighbors": neighbors,
|
|
||||||
**health_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/regions")
|
|
||||||
async def get_regions(request: Request):
|
|
||||||
"""Get region summaries."""
|
|
||||||
health_engine = request.app.state.health_engine
|
|
||||||
|
|
||||||
if not health_engine or not health_engine.mesh_health:
|
|
||||||
return []
|
|
||||||
|
|
||||||
regions = []
|
|
||||||
for region in health_engine.mesh_health.regions:
|
|
||||||
# Count online infrastructure
|
|
||||||
infra_online = 0
|
|
||||||
infra_total = 0
|
|
||||||
online_count = 0
|
|
||||||
|
|
||||||
for nid in region.node_ids:
|
|
||||||
node = health_engine.mesh_health.nodes.get(nid)
|
|
||||||
if node:
|
|
||||||
if node.is_online:
|
|
||||||
online_count += 1
|
|
||||||
if node.is_infrastructure:
|
|
||||||
infra_total += 1
|
|
||||||
if node.is_online:
|
|
||||||
infra_online += 1
|
|
||||||
|
|
||||||
regions.append({
|
|
||||||
"name": region.name,
|
|
||||||
"local_name": region.name, # Could be overridden by region_labels
|
|
||||||
"node_count": len(region.node_ids),
|
|
||||||
"infra_count": infra_total,
|
|
||||||
"infra_online": infra_online,
|
|
||||||
"online_count": online_count,
|
|
||||||
"score": round(region.score.composite, 1),
|
|
||||||
"tier": region.score.tier,
|
|
||||||
"center_lat": region.center_lat,
|
|
||||||
"center_lon": region.center_lon,
|
|
||||||
})
|
|
||||||
|
|
||||||
return regions
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
|
||||||
async def get_sources(request: Request):
|
|
||||||
"""Get per-source health information."""
|
|
||||||
data_store = request.app.state.data_store
|
|
||||||
|
|
||||||
if not data_store:
|
|
||||||
return []
|
|
||||||
|
|
||||||
sources = []
|
|
||||||
try:
|
|
||||||
for name, source in data_store._sources.items():
|
|
||||||
source_info = {
|
|
||||||
"name": name,
|
|
||||||
"type": "meshview" if hasattr(source, "edges") else "meshmonitor",
|
|
||||||
"url": getattr(source, "url", ""),
|
|
||||||
"is_loaded": source.is_loaded,
|
|
||||||
"last_error": source.last_error,
|
|
||||||
"consecutive_errors": getattr(source, "consecutive_errors", 0),
|
|
||||||
"response_time_ms": getattr(source, "last_response_time_ms", None),
|
|
||||||
"tick_count": getattr(source, "tick_count", 0),
|
|
||||||
"node_count": len(source.nodes) if hasattr(source, "nodes") else 0,
|
|
||||||
}
|
|
||||||
sources.append(source_info)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return sources
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/edges")
|
|
||||||
async def get_edges(request: Request):
|
|
||||||
"""Get neighbor/edge relationships."""
|
|
||||||
data_store = request.app.state.data_store
|
|
||||||
|
|
||||||
if not data_store:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_edges = data_store.get_all_edges()
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
edges = []
|
|
||||||
for edge in raw_edges:
|
|
||||||
from_num = edge.get("from_node") or edge.get("from")
|
|
||||||
to_num = edge.get("to_node") or edge.get("to")
|
|
||||||
snr = edge.get("snr")
|
|
||||||
|
|
||||||
# Derive quality from SNR
|
|
||||||
if snr is None:
|
|
||||||
quality = "unknown"
|
|
||||||
elif snr > 12:
|
|
||||||
quality = "excellent"
|
|
||||||
elif snr > 8:
|
|
||||||
quality = "good"
|
|
||||||
elif snr > 5:
|
|
||||||
quality = "fair"
|
|
||||||
elif snr > 3:
|
|
||||||
quality = "marginal"
|
|
||||||
else:
|
|
||||||
quality = "poor"
|
|
||||||
|
|
||||||
edges.append({
|
|
||||||
"from_node": from_num,
|
|
||||||
"to_node": to_num,
|
|
||||||
"snr": snr,
|
|
||||||
"quality": quality,
|
|
||||||
})
|
|
||||||
|
|
||||||
return edges
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"""System status and control API routes."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
from meshai import __version__
|
|
||||||
from meshai.commands.status import _start_time
|
|
||||||
|
|
||||||
router = APIRouter(tags=["system"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def get_status(request: Request):
|
|
||||||
"""Get system status information."""
|
|
||||||
config = request.app.state.config
|
|
||||||
data_store = request.app.state.data_store
|
|
||||||
|
|
||||||
# Calculate uptime
|
|
||||||
uptime_seconds = time.time() - _start_time if _start_time else 0
|
|
||||||
|
|
||||||
# Connection info
|
|
||||||
conn = config.connection
|
|
||||||
if conn.type == "tcp":
|
|
||||||
connection_target = f"{conn.tcp_host}:{conn.tcp_port}"
|
|
||||||
else:
|
|
||||||
connection_target = conn.serial_port
|
|
||||||
|
|
||||||
# Count nodes and sources
|
|
||||||
node_count = 0
|
|
||||||
source_count = 0
|
|
||||||
connected = False
|
|
||||||
|
|
||||||
if data_store:
|
|
||||||
try:
|
|
||||||
nodes = data_store.get_all_nodes()
|
|
||||||
node_count = len(nodes) if nodes else 0
|
|
||||||
source_count = data_store.source_count
|
|
||||||
connected = any(s.is_loaded for s in data_store._sources.values())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": __version__,
|
|
||||||
"uptime_seconds": round(uptime_seconds, 1),
|
|
||||||
"bot_name": config.bot.name,
|
|
||||||
"connection_type": conn.type,
|
|
||||||
"connection_target": connection_target,
|
|
||||||
"connected": connected,
|
|
||||||
"node_count": node_count,
|
|
||||||
"source_count": source_count,
|
|
||||||
"env_feeds_enabled": request.app.state.env_store is not None,
|
|
||||||
"dashboard_port": config.dashboard.port,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
|
||||||
async def restart_bot():
|
|
||||||
"""Signal the bot to restart."""
|
|
||||||
restart_file = Path("/tmp/meshai_restart")
|
|
||||||
restart_file.touch()
|
|
||||||
return {"restarting": True}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
"""FastAPI server for MeshAI dashboard."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from .ws import DashboardBroadcaster, router as ws_router
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..main import MeshAI
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""FastAPI lifespan context manager."""
|
|
||||||
logger.info("Dashboard starting up")
|
|
||||||
yield
|
|
||||||
logger.info("Dashboard shutting down")
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
|
||||||
"""Create and configure the FastAPI application."""
|
|
||||||
app = FastAPI(
|
|
||||||
title="MeshAI Dashboard",
|
|
||||||
description="Web dashboard for MeshAI mesh network monitoring",
|
|
||||||
version="0.1.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS middleware for Vite dev server
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:5173", "http://localhost:8080"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import and include API routers
|
|
||||||
from .api.system_routes import router as system_router
|
|
||||||
from .api.config_routes import router as config_router
|
|
||||||
from .api.mesh_routes import router as mesh_router
|
|
||||||
from .api.env_routes import router as env_router
|
|
||||||
from .api.alert_routes import router as alert_router
|
|
||||||
|
|
||||||
app.include_router(system_router, prefix="/api")
|
|
||||||
app.include_router(config_router, prefix="/api")
|
|
||||||
app.include_router(mesh_router, prefix="/api")
|
|
||||||
app.include_router(env_router, prefix="/api")
|
|
||||||
app.include_router(alert_router, prefix="/api")
|
|
||||||
|
|
||||||
# WebSocket router (no prefix, path is /ws/live)
|
|
||||||
app.include_router(ws_router)
|
|
||||||
|
|
||||||
# Static files - mount LAST so /api routes take priority
|
|
||||||
static_dir = Path(__file__).parent / "static"
|
|
||||||
if static_dir.exists():
|
|
||||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
|
|
||||||
"""Start the dashboard server in the MeshAI asyncio loop.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
meshai_instance: The running MeshAI instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DashboardBroadcaster instance for pushing updates
|
|
||||||
"""
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
# Populate app.state with MeshAI internals
|
|
||||||
app.state.config = meshai_instance.config
|
|
||||||
app.state.config_path = meshai_instance.config._config_path
|
|
||||||
app.state.data_store = meshai_instance.data_store
|
|
||||||
app.state.health_engine = meshai_instance.health_engine
|
|
||||||
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
|
||||||
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
|
||||||
app.state.subscription_manager = meshai_instance.subscription_manager
|
|
||||||
|
|
||||||
# Create broadcaster and attach to app state
|
|
||||||
broadcaster = DashboardBroadcaster()
|
|
||||||
app.state.broadcaster = broadcaster
|
|
||||||
|
|
||||||
# Configure uvicorn
|
|
||||||
config = uvicorn.Config(
|
|
||||||
app,
|
|
||||||
host=meshai_instance.config.dashboard.host,
|
|
||||||
port=meshai_instance.config.dashboard.port,
|
|
||||||
log_level="warning", # Don't spam meshai logs with access logs
|
|
||||||
)
|
|
||||||
server = uvicorn.Server(config)
|
|
||||||
|
|
||||||
# Start server as asyncio task (runs in same event loop as MeshAI)
|
|
||||||
asyncio.create_task(server.serve())
|
|
||||||
|
|
||||||
return broadcaster
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,17 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>MeshAI Dashboard</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<script type="module" crossorigin src="/assets/index-DnO02g6m.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DdqEB3wX.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -51,7 +51,6 @@ class MeshAI:
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._last_cleanup: float = 0.0
|
self._last_cleanup: float = 0.0
|
||||||
self._last_health_compute: float = 0.0
|
self._last_health_compute: float = 0.0
|
||||||
self.broadcaster = None # Dashboard WebSocket broadcaster
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the bot."""
|
"""Start the bot."""
|
||||||
|
|
@ -95,37 +94,12 @@ class MeshAI:
|
||||||
self.health_engine.compute(self.data_store)
|
self.health_engine.compute(self.data_store)
|
||||||
self._last_health_compute = time.time()
|
self._last_health_compute = time.time()
|
||||||
|
|
||||||
# Broadcast health update to dashboard
|
|
||||||
if self.broadcaster and self.health_engine.mesh_health:
|
|
||||||
try:
|
|
||||||
mh = self.health_engine.mesh_health
|
|
||||||
health_dict = {
|
|
||||||
"score": round(mh.score.composite, 1),
|
|
||||||
"tier": mh.score.tier,
|
|
||||||
"total_nodes": mh.total_nodes,
|
|
||||||
"total_regions": mh.total_regions,
|
|
||||||
"infra_online": mh.score.infra_online,
|
|
||||||
"infra_total": mh.score.infra_total,
|
|
||||||
"last_computed": mh.last_computed,
|
|
||||||
}
|
|
||||||
await self.broadcaster.broadcast("health_update", health_dict)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Dashboard broadcast error: %s", e)
|
|
||||||
|
|
||||||
# Check for alertable conditions
|
# Check for alertable conditions
|
||||||
if self.alert_engine:
|
if self.alert_engine:
|
||||||
alerts = self.alert_engine.check()
|
alerts = self.alert_engine.check()
|
||||||
if alerts:
|
if alerts:
|
||||||
await self._dispatch_alerts(alerts)
|
await self._dispatch_alerts(alerts)
|
||||||
|
|
||||||
# Broadcast alerts to dashboard
|
|
||||||
if self.broadcaster:
|
|
||||||
for alert in alerts:
|
|
||||||
try:
|
|
||||||
await self.broadcaster.broadcast("alert_fired", alert)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check scheduled subscriptions (every 60 seconds)
|
# Check scheduled subscriptions (every 60 seconds)
|
||||||
if self.subscription_manager and self.mesh_reporter:
|
if self.subscription_manager and self.mesh_reporter:
|
||||||
if time.time() - self._last_sub_check >= 60:
|
if time.time() - self._last_sub_check >= 60:
|
||||||
|
|
@ -371,18 +345,6 @@ class MeshAI:
|
||||||
# Responder
|
# Responder
|
||||||
self.responder = Responder(self.config.response, self.connector)
|
self.responder = Responder(self.config.response, self.connector)
|
||||||
|
|
||||||
# Dashboard
|
|
||||||
if hasattr(self.config, 'dashboard') and self.config.dashboard.enabled:
|
|
||||||
try:
|
|
||||||
from .dashboard.server import start_dashboard
|
|
||||||
self.broadcaster = await start_dashboard(self)
|
|
||||||
logger.info("Dashboard started on port %d", self.config.dashboard.port)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Dashboard failed to start: %s", e)
|
|
||||||
self.broadcaster = None
|
|
||||||
else:
|
|
||||||
self.broadcaster = None
|
|
||||||
|
|
||||||
async def _on_message(self, message: MeshMessage) -> None:
|
async def _on_message(self, message: MeshMessage) -> None:
|
||||||
"""Handle incoming message."""
|
"""Handle incoming message."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ dependencies = [
|
||||||
"google-genai>=1.0.0",
|
"google-genai>=1.0.0",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"fastapi>=0.110.0",
|
|
||||||
"uvicorn[standard]>=0.27.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,3 @@ httpx>=0.25.0
|
||||||
fastembed>=0.3.0
|
fastembed>=0.3.0
|
||||||
sqlite-vec>=0.1.0
|
sqlite-vec>=0.1.0
|
||||||
numpy
|
numpy
|
||||||
fastapi>=0.110.0
|
|
||||||
uvicorn[standard]>=0.27.0
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue