Compare commits

...

3 commits

Author SHA1 Message Date
374fb835c5 fix(dashboard): content scroll overflow bug 2026-05-12 16:46:51 +00:00
4331bcb7e1 feat(dashboard): React frontend scaffold with overview page
- Vite + React 18 + TypeScript + Tailwind CSS
- Dashboard overview with health gauge, pillar bars, alerts
- WebSocket hook for real-time updates
- Layout with sidebar navigation and live indicator
- Placeholder pages for Mesh, Environment, Config, Alerts
- Dark theme ops center aesthetic with JetBrains Mono

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 10:28:12 -06:00
3ec09ad158 feat(dashboard): embedded FastAPI backend with REST API + WebSocket
- FastAPI runs in MeshAI asyncio loop (no separate process)
- REST API: /api/status, /api/health, /api/nodes, /api/edges,
  /api/regions, /api/sources, /api/config, /api/alerts
- WebSocket at /ws/live pushes health updates and alerts
- Config CRUD: GET/PUT per section with validation and save
- DashboardConfig with port/host in config.yaml
2026-05-12 15:47:58 +00:00
38 changed files with 2373 additions and 103 deletions

View file

@ -78,7 +78,7 @@ USER meshai
VOLUME ["/data"] VOLUME ["/data"]
# Expose ttyd web config port # Expose ttyd web config port
EXPOSE 7682 EXPOSE 7682 8080
# 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 \

View file

@ -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,3 +123,9 @@ 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"

View file

@ -0,0 +1,16 @@
<!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>

View file

@ -0,0 +1,28 @@
{
"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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,23 @@
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

View file

@ -0,0 +1,162 @@
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>
)
}

View file

@ -0,0 +1,102 @@
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 }
}

View file

@ -0,0 +1,49 @@
@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;
}

View file

@ -0,0 +1,157 @@
// 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')
}

View file

@ -0,0 +1,13 @@
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>,
)

View file

@ -0,0 +1,15 @@
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>
)
}

View file

@ -0,0 +1,15 @@
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>
)
}

View file

@ -0,0 +1,381 @@
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>
)
}

View file

@ -0,0 +1,15 @@
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>
)
}

View file

@ -0,0 +1,15 @@
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 Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,26 @@
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

View file

@ -0,0 +1,25 @@
{
"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" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,28 @@
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,
},
},
},
})

View file

@ -35,6 +35,8 @@ 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)

View file

@ -257,6 +257,16 @@ 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."""
@ -274,6 +284,7 @@ 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)

View file

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

View file

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

View file

@ -0,0 +1,85 @@
"""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 []

View file

@ -0,0 +1,183 @@
"""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)}

View file

@ -0,0 +1,44 @@
"""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}

View file

@ -0,0 +1,356 @@
"""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

View file

@ -0,0 +1,63 @@
"""System status and control API routes."""
import time
from pathlib import Path
from fastapi import APIRouter, Request
from meshai import __version__
from meshai.commands.status import _start_time
router = APIRouter(tags=["system"])
@router.get("/status")
async def get_status(request: Request):
"""Get system status information."""
config = request.app.state.config
data_store = request.app.state.data_store
# Calculate uptime
uptime_seconds = time.time() - _start_time if _start_time else 0
# Connection info
conn = config.connection
if conn.type == "tcp":
connection_target = f"{conn.tcp_host}:{conn.tcp_port}"
else:
connection_target = conn.serial_port
# Count nodes and sources
node_count = 0
source_count = 0
connected = False
if data_store:
try:
nodes = data_store.get_all_nodes()
node_count = len(nodes) if nodes else 0
source_count = data_store.source_count
connected = any(s.is_loaded for s in data_store._sources.values())
except Exception:
pass
return {
"version": __version__,
"uptime_seconds": round(uptime_seconds, 1),
"bot_name": config.bot.name,
"connection_type": conn.type,
"connection_target": connection_target,
"connected": connected,
"node_count": node_count,
"source_count": source_count,
"env_feeds_enabled": request.app.state.env_store is not None,
"dashboard_port": config.dashboard.port,
}
@router.post("/restart")
async def restart_bot():
"""Signal the bot to restart."""
restart_file = Path("/tmp/meshai_restart")
restart_file.touch()
return {"restarting": True}

108
meshai/dashboard/server.py Normal file
View file

@ -0,0 +1,108 @@
"""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

View file

@ -0,0 +1,17 @@
<!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>

115
meshai/dashboard/ws.py Normal file
View file

@ -0,0 +1,115 @@
"""WebSocket support for real-time dashboard updates."""
import logging
from typing import Set
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
router = APIRouter()
class DashboardBroadcaster:
"""Manages active WebSocket connections for real-time updates."""
def __init__(self):
self._connections: Set[WebSocket] = set()
async def connect(self, websocket: WebSocket) -> None:
"""Accept and register a new WebSocket connection."""
await websocket.accept()
self._connections.add(websocket)
logger.debug(f"WebSocket connected, total: {len(self._connections)}")
def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection."""
self._connections.discard(websocket)
logger.debug(f"WebSocket disconnected, total: {len(self._connections)}")
async def broadcast(self, msg_type: str, data: dict) -> None:
"""Broadcast a message to all connected clients.
Args:
msg_type: Message type (e.g., "health_update", "alert_fired")
data: Message payload
"""
if not self._connections:
return
message = {"type": msg_type, "data": data}
dead_connections = set()
for websocket in self._connections:
try:
await websocket.send_json(message)
except Exception as e:
logger.debug(f"WebSocket send failed: {e}")
dead_connections.add(websocket)
# Remove dead connections
for ws in dead_connections:
self._connections.discard(ws)
@property
def connection_count(self) -> int:
"""Get number of active connections."""
return len(self._connections)
def _serialize_health(mesh_health) -> dict:
"""Serialize MeshHealth for WebSocket transmission."""
if not mesh_health:
return {"score": 0, "tier": "Unknown", "message": "No data"}
score = mesh_health.score
return {
"score": round(score.composite, 1),
"tier": score.tier,
"pillars": {
"infrastructure": round(score.infrastructure, 1),
"utilization": round(score.utilization, 1),
"behavior": round(score.behavior, 1),
"power": round(score.power, 1),
},
"infra_online": score.infra_online,
"infra_total": score.infra_total,
"util_percent": round(score.util_percent, 1),
"flagged_nodes": score.flagged_nodes,
"battery_warnings": score.battery_warnings,
"total_nodes": mesh_health.total_nodes,
"total_regions": mesh_health.total_regions,
"last_computed": mesh_health.last_computed,
}
@router.websocket("/ws/live")
async def ws_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates."""
# Get broadcaster from app state
app_state = websocket.app.state
broadcaster = getattr(app_state, "broadcaster", None)
if not broadcaster:
await websocket.close(code=1011, reason="Broadcaster not initialized")
return
await broadcaster.connect(websocket)
try:
# Send initial state snapshot on connect
health_engine = getattr(app_state, "health_engine", None)
if health_engine and health_engine.mesh_health:
await websocket.send_json({
"type": "health_update",
"data": _serialize_health(health_engine.mesh_health)
})
# Keep connection alive, receive client keepalive pings
while True:
await websocket.receive_text()
except WebSocketDisconnect:
broadcaster.disconnect(websocket)
except Exception as e:
logger.debug(f"WebSocket error: {e}")
broadcaster.disconnect(websocket)

View file

@ -51,6 +51,7 @@ 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."""
@ -94,12 +95,37 @@ 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:
@ -345,6 +371,18 @@ 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:

View file

@ -36,6 +36,8 @@ 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]

View file

@ -9,3 +9,5 @@ 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