feat: initial KosmoConnect platform v0.1
Some checks failed
CI / lint-docs (push) Has been cancelled
CI / build-firmware (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / test-web (push) Has been cancelled

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:
2026-04-12 17:30:15 +02:00
commit 0a4fb7b55e
95 changed files with 9903 additions and 0 deletions

40
web/dashboard/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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

View 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>
)
}

View 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>
)
}

View 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 }
}

View 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;
}

View 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>,
)

View 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,
}
}
}
})