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:
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>,
|
||||
)
|
||||
Reference in New Issue
Block a user