feat: initial KosmoConnect platform v0.1
Includes: - Backend services: ingestion (:8001), weather API (:8002), gateway (:8003), billing (:8004) with BTCPay integration - Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT - React frontend: Dashboard (MapLibre) and Messaging (chat UI) - Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support) - Production Docker Compose, Nginx reverse proxy, ops scripts - DEPLOY.md with step-by-step deployment guide
This commit is contained in:
40
web/dashboard/README.md
Normal file
40
web/dashboard/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# KosmoConnect Dashboard
|
||||
|
||||
Public weather and node health visualization for the KosmoConnect mesh network.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 18** + **Vite**
|
||||
- **MapLibre GL** (open-source Mapbox alternative)
|
||||
- CartoDB Voyager basemap (free, no API key required)
|
||||
|
||||
## Running Locally
|
||||
|
||||
Make sure the backend API is running on `http://localhost:8002` (see `backend/README.md`).
|
||||
|
||||
```bash
|
||||
cd web/dashboard
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser.
|
||||
|
||||
## Features (v0.1)
|
||||
|
||||
- Interactive map showing all registered enviro-nodes
|
||||
- Live sensor readings inside node popups (temperature, humidity, pressure, wind, PM2.5, PM10, battery, solar)
|
||||
- Auto-refresh every 15 seconds
|
||||
- Automatic map bounds fitting so all nodes are visible
|
||||
- Dark UI theme aligned with Church of Kosmo aesthetics
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VITE_API_BASE` | `http://localhost:8002` | Base URL for the KosmoConnect API |
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- The Vite dev server proxies `/api` requests to `localhost:8002` to avoid CORS issues during development.
|
||||
- In production, the dashboard is served as static files and talks directly to the API host.
|
||||
13
web/dashboard/index.html
Normal file
13
web/dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KosmoConnect Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2130
web/dashboard/package-lock.json
generated
Normal file
2130
web/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/dashboard/package.json
Normal file
22
web/dashboard/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "kosmoconnect-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
28
web/dashboard/src/App.jsx
Normal file
28
web/dashboard/src/App.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import MapView from './components/MapView'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<header style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: '#0f172a',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, color: '#38bdf8' }}>
|
||||
KosmoConnect
|
||||
</h1>
|
||||
<span style={{ fontSize: '0.875rem', color: '#94a3b8' }}>
|
||||
Environmental Intelligence & Emergency Mesh Network
|
||||
</span>
|
||||
</header>
|
||||
<main style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
<MapView />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
120
web/dashboard/src/components/MapView.jsx
Normal file
120
web/dashboard/src/components/MapView.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useNodes } from '../hooks/useApi'
|
||||
import NodePopup from './NodePopup'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// Free CartoDB Voyager style for MapLibre
|
||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
|
||||
|
||||
export default function MapView() {
|
||||
const mapContainerRef = useRef(null)
|
||||
const mapRef = useRef(null)
|
||||
const markersRef = useRef([])
|
||||
const { nodes, error } = useNodes(15000)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: MAP_STYLE,
|
||||
center: [10, 50], // default center (Europe-ish)
|
||||
zoom: 4,
|
||||
})
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
mapRef.current = map
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
// clear existing markers
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current = []
|
||||
|
||||
if (!nodes.length) return
|
||||
|
||||
// compute bounds to fit all nodes with coordinates
|
||||
const coords = nodes.filter(n => n.lat != null && n.lon != null).map(n => [n.lon, n.lat])
|
||||
|
||||
nodes.forEach(node => {
|
||||
const el = document.createElement('div')
|
||||
el.style.width = '18px'
|
||||
el.style.height = '18px'
|
||||
el.style.borderRadius = '50%'
|
||||
el.style.background = node.last_seen ? '#22c55e' : '#64748b'
|
||||
el.style.border = '2px solid #0f172a'
|
||||
el.style.boxShadow = '0 0 0 2px #38bdf8'
|
||||
el.style.cursor = 'pointer'
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([node.lon ?? 0, node.lat ?? 0])
|
||||
.addTo(map)
|
||||
|
||||
const popupContainer = document.createElement('div')
|
||||
const root = createRoot(popupContainer)
|
||||
root.render(<NodePopup node={node} />)
|
||||
|
||||
const popup = new maplibregl.Popup({
|
||||
offset: 20,
|
||||
className: 'kosmo-popup',
|
||||
maxWidth: '320px'
|
||||
}).setDOMContent(popupContainer)
|
||||
|
||||
marker.setPopup(popup)
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
if (coords.length) {
|
||||
const bounds = coords.reduce(
|
||||
(b, c) => b.extend(c),
|
||||
new maplibregl.LngLatBounds(coords[0], coords[0])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: 60, maxZoom: 12, duration: 1000 })
|
||||
}
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
|
||||
{error && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
left: '1rem',
|
||||
background: '#7f1d1d',
|
||||
color: '#fecaca',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.875rem',
|
||||
zIndex: 10
|
||||
}}>
|
||||
API Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '1rem',
|
||||
left: '1rem',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
color: '#94a3b8',
|
||||
padding: '0.35rem 0.6rem',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}>
|
||||
Nodes online: {nodes.filter(n => n.is_active).length} · Total: {nodes.length}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
web/dashboard/src/components/NodePopup.jsx
Normal file
47
web/dashboard/src/components/NodePopup.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useLatest } from '../hooks/useApi'
|
||||
|
||||
function fmt(v, unit) {
|
||||
if (v == null) return '—'
|
||||
return `${Number(v).toFixed(1)} ${unit}`
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '—'
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return `${sec}s ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
return `${hr}h ago`
|
||||
}
|
||||
|
||||
export default function NodePopup({ node }) {
|
||||
const { reading } = useLatest(node.mesh_node_id)
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '220px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '1rem', marginBottom: '0.25rem', color: '#38bdf8' }}>
|
||||
{node.name || node.mesh_node_id}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '0.5rem' }}>
|
||||
{node.mesh_node_id} · {timeAgo(node.last_seen)}
|
||||
</div>
|
||||
|
||||
{reading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.35rem 0.75rem', fontSize: '0.875rem' }}>
|
||||
<div><span style={{ color: '#94a3b8' }}>Temp</span><br/><strong>{fmt(reading.temperature_c, '°C')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>Humidity</span><br/><strong>{fmt(reading.humidity_percent, '%')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>Pressure</span><br/><strong>{fmt(reading.pressure_pa / 100, 'hPa')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>Wind</span><br/><strong>{fmt(reading.wind_speed_ms, 'm/s')} {reading.wind_direction != null ? `@ ${reading.wind_direction}°` : ''}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>PM2.5</span><br/><strong>{fmt(reading.pm25_ugm3, 'µg/m³')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>PM10</span><br/><strong>{fmt(reading.pm10_ugm3, 'µg/m³')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>Battery</span><br/><strong>{fmt(reading.battery_voltage, 'V')}</strong></div>
|
||||
<div><span style={{ color: '#94a3b8' }}>Solar</span><br/><strong>{fmt(reading.solar_voltage, 'V')}</strong></div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.875rem', color: '#94a3b8' }}>No recent readings available.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
web/dashboard/src/hooks/useApi.js
Normal file
62
web/dashboard/src/hooks/useApi.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8002'
|
||||
|
||||
export function useNodes(refreshMs = 15000) {
|
||||
const [nodes, setNodes] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function fetchNodes() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/nodes`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
if (!cancelled) setNodes(json.data || [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fetchNodes()
|
||||
const id = setInterval(fetchNodes, refreshMs)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [refreshMs])
|
||||
|
||||
return { nodes, error }
|
||||
}
|
||||
|
||||
export function useLatest(nodeId) {
|
||||
const [reading, setReading] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeId) return
|
||||
let cancelled = false
|
||||
|
||||
async function fetchLatest() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/weather/latest?node_id=${encodeURIComponent(nodeId)}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
if (!cancelled) setReading((json.data || [])[0] || null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fetchLatest()
|
||||
const id = setInterval(fetchLatest, 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [nodeId])
|
||||
|
||||
return { reading, error }
|
||||
}
|
||||
53
web/dashboard/src/index.css
Normal file
53
web/dashboard/src/index.css
Normal file
@@ -0,0 +1,53 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #0f172a;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.kosmo-popup .maplibregl-popup-content {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #334155;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.kosmo-popup .maplibregl-popup-tip {
|
||||
border-top-color: #1e293b;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
color: #f8fafc;
|
||||
background: transparent;
|
||||
}
|
||||
10
web/dashboard/src/main.jsx
Normal file
10
web/dashboard/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
16
web/dashboard/vite.config.js
Normal file
16
web/dashboard/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8002',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user