mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
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>
This commit is contained in:
parent
3ec09ad158
commit
4331bcb7e1
19 changed files with 1088 additions and 0 deletions
16
dashboard-frontend/index.html
Normal file
16
dashboard-frontend/index.html
Normal 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>
|
||||||
28
dashboard-frontend/package.json
Normal file
28
dashboard-frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
dashboard-frontend/postcss.config.js
Normal file
6
dashboard-frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
23
dashboard-frontend/src/App.tsx
Normal file
23
dashboard-frontend/src/App.tsx
Normal 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
|
||||||
162
dashboard-frontend/src/components/Layout.tsx
Normal file
162
dashboard-frontend/src/components/Layout.tsx
Normal 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 min-h-screen bg-bg text-slate-200">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-[220px] bg-bg-card border-r border-border flex flex-col">
|
||||||
|
{/* 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="ml-[220px] flex-1 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-14 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 p-6 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
dashboard-frontend/src/hooks/useWebSocket.ts
Normal file
102
dashboard-frontend/src/hooks/useWebSocket.ts
Normal 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 }
|
||||||
|
}
|
||||||
49
dashboard-frontend/src/index.css
Normal file
49
dashboard-frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
157
dashboard-frontend/src/lib/api.ts
Normal file
157
dashboard-frontend/src/lib/api.ts
Normal 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')
|
||||||
|
}
|
||||||
13
dashboard-frontend/src/main.tsx
Normal file
13
dashboard-frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
15
dashboard-frontend/src/pages/Alerts.tsx
Normal file
15
dashboard-frontend/src/pages/Alerts.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
dashboard-frontend/src/pages/Config.tsx
Normal file
15
dashboard-frontend/src/pages/Config.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
dashboard-frontend/src/pages/Dashboard.tsx
Normal file
381
dashboard-frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
dashboard-frontend/src/pages/Environment.tsx
Normal file
15
dashboard-frontend/src/pages/Environment.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
dashboard-frontend/src/pages/Mesh.tsx
Normal file
15
dashboard-frontend/src/pages/Mesh.tsx
Normal 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
1
dashboard-frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
26
dashboard-frontend/tailwind.config.ts
Normal file
26
dashboard-frontend/tailwind.config.ts
Normal 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
|
||||||
25
dashboard-frontend/tsconfig.json
Normal file
25
dashboard-frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
11
dashboard-frontend/tsconfig.node.json
Normal file
11
dashboard-frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
28
dashboard-frontend/vite.config.ts
Normal file
28
dashboard-frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue