epaper-display/LORAWEB_NEU/docs/superpowers/specs/2026-03-19-loraweb-design.md
Christian Mueller e8e2a7dd6d Address spec review findings: SQLite concurrency, TOTP flow, InfluxDB bootstrap
- Add WAL mode + busy timeout for SQLite concurrent access
- Define two-step TOTP login protocol with temporary JWT
- Specify JWT claims structure and error response format
- Add InfluxDB bootstrap via Docker init env vars
- Clarify CORS strategy (same-origin in prod, permissive in dev)
- Add daemon-config.toml to deploy directory
- Clarify event routing (single POST / handler)
- Note sensor delete uses SQLite transaction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:30:46 +01:00

355 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LoRaWAN Web Portal Design Specification
**Date:** 2026-03-19
**Based on:** Pflichtenheft v2.2 (März 2026)
**Approach:** Bottom-up implementation, pflichtenheft as goal with flexibility in implementation details
---
## 1. Overview
Web-based monitoring portal for LoRaWAN sensor infrastructure. Receives uplink data from IoT devices via ChirpStack HTTP Integration, stores measurements in InfluxDB, manages device/sensor configuration in SQLite, and presents everything through a secured React frontend.
### Key Decisions (Deviations from Pflichtenheft)
| Area | Pflichtenheft | Implementation | Reason |
|------|--------------|----------------|--------|
| SQLx | 0.7 | 0.8 | Current version, better SQLite support |
| Axum | 0.7 | 0.8 | Current version, improved API |
| Vite | 5 | 6 | Faster builds, no breaking changes for us |
| Tailwind | 3.4 | 4 | CSS-based engine, better performance |
| Rust Edition | 2021 | 2024 | Current standard |
| Deploy path | — | /opt/loraweb | As specified in pflichtenheft |
React 18, `config` crate, and core architecture remain unchanged.
---
## 2. Project Structure
```
LORAWEB_NEU/
├── Cargo.toml # Workspace root (members: backend/daemon, backend/api)
├── backend/
│ ├── daemon/
│ │ ├── Cargo.toml
│ │ ├── Dockerfile
│ │ └── src/
│ │ ├── main.rs # Tokio runtime, task spawning
│ │ ├── config.rs # AppConfig via config crate
│ │ ├── http.rs # Axum HTTP ingest listener
│ │ ├── influx.rs # InfluxDB writer
│ │ ├── db.rs # Topic registration (SQLite)
│ │ └── alarm.rs # Alarm monitor loop
│ └── api/
│ ├── Cargo.toml
│ ├── Dockerfile
│ └── src/
│ ├── main.rs # Router, DB init, AppState
│ ├── auth.rs # JWT, Argon2, TOTP
│ ├── sensors.rs # CRUD + data + display-config
│ ├── topics.rs # Topic management
│ ├── alarms.rs # Alarm endpoints
│ └── users.rs # User CRUD + 2FA
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Router, protected routes
│ │ ├── api/client.ts # Axios instance + API functions
│ │ ├── store/authStore.ts # React Context auth state
│ │ ├── pages/
│ │ │ ├── LoginPage.tsx
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── SensorsPage.tsx # DataModal, multi-chart, mobile cards
│ │ │ ├── UnknownTopicsPage.tsx
│ │ │ ├── AlarmsPage.tsx
│ │ │ └── UsersPage.tsx
│ │ └── components/
│ │ ├── Layout.tsx # Responsive sidebar + hamburger drawer
│ │ └── StatusBadge.tsx
│ ├── Dockerfile
│ └── nginx.conf
├── database/
│ └── migrations/001_initial.sql
├── deploy/
│ ├── docker-compose.yml # Project name: loraweb
│ ├── .env.example
│ ├── daemon-config.toml # Daemon config example (not in git at runtime)
│ └── loraweb.service # systemd unit
└── docs/
```
---
## 3. Database Schema (SQLite)
Three tables, created idempotently (`CREATE TABLE IF NOT EXISTS`) at startup by both daemon and API.
### SQLite Concurrency
Both daemon and API access the same SQLite file. To handle concurrent access:
- **WAL mode** (`PRAGMA journal_mode=WAL`) enabled at connection open — allows concurrent readers during writes
- **Busy timeout** (`PRAGMA busy_timeout=5000`) — retries on lock contention instead of failing immediately
- Both services set these pragmas on every connection open
### topics
Represents a physical LoRaWAN device identified by DevEUI.
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT (UUID v4) | Primary key |
| topic | TEXT UNIQUE | DevEUI |
| approved | INTEGER (0/1) | 0 = unknown, 1 = approved |
| last_seen | TEXT (RFC3339) | Last uplink timestamp |
| alarm_status | TEXT | active / warning / alarm / unknown |
| alarm_threshold_hours | REAL | Per-device threshold (0 = use global) |
| created_at | TEXT (RFC3339) | First registration |
### sensors
Logical sensor entity assigned to a topic.
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT (UUID v4) | Primary key |
| name | TEXT | Display name |
| description | TEXT | Optional description |
| location | TEXT | Location string |
| sensor_type | TEXT | Type (e.g. "Temperatur") |
| active | INTEGER (0/1) | Active flag |
| topic_id | TEXT FK → topics.id | Assigned topic (nullable) |
| display_config | TEXT (JSON) | Per-sensor field config |
| created_at | TEXT (RFC3339) | Creation date |
### users
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK AUTOINCREMENT | Primary key |
| username | TEXT UNIQUE | Username |
| password_hash | TEXT | Argon2id hash |
| role | TEXT | "user" or "admin" |
| totp_secret | TEXT | Base32 TOTP secret (nullable) |
| totp_enabled | INTEGER (0/1) | 2FA active |
| created_at | TEXT (RFC3339) | Creation date |
---
## 4. Daemon Design
Single Rust binary running two async tasks via Tokio:
### 4.1 HTTP Ingest (Axum, Port 8080)
- `POST /?event=up`: Parse ChirpStack JSON → register unknown DevEUI in `topics` (approved=0) → write all `object` fields + RF metadata (rssi, snr, dr, f_cnt, f_port) to InfluxDB measurement `sensor_data`
- Tags: `dev_eui`, `device_name`, `application_name`
- Timestamp from ChirpStack `time` field
- Routing: single `POST /` handler checks `?event=` query parameter. Only `event=up` is processed; all other values return 200 OK with no further action
- InfluxDB errors: log at `warn` level and continue, no crash
### 4.2 Alarm Monitor (Tokio interval loop)
- Runs every `check_interval_secs` (default 60)
- Queries all topics with `approved=1`
- Per topic: use `alarm_threshold_hours` if > 0, otherwise global threshold
- Status logic:
- `last_seen` is NULL → `unknown`
- hours since last seen < warning threshold `active`
- hours since last seen < alarm threshold `warning`
- hours since last seen >= alarm threshold → `alarm`
- Updates `alarm_status` in SQLite
### 4.3 Configuration
Via `config` crate: loads `daemon-config.toml` + environment variables (prefix `LORAWEB__`).
| Key | Default | Description |
|-----|---------|-------------|
| http.bind | 0.0.0.0 | Bind address |
| http.port | 8080 | HTTP port |
| influx.url | http://localhost:8086 | InfluxDB URL |
| influx.token | — | API token |
| influx.org | loraweb | Organization |
| influx.bucket | sensors | Bucket |
| database.path | loraweb.db | SQLite path |
| alarm.warning_threshold_hours | 2.0 | Warning threshold |
| alarm.alarm_threshold_hours | 6.0 | Alarm threshold |
| alarm.check_interval_secs | 60 | Check interval |
### 4.4 Startup
Log banner showing ingest URL and ChirpStack configuration hint.
---
## 5. REST API Design
Axum on port 3001, shared SQLite DB with daemon.
### 5.1 Authentication
- `POST /api/auth/login`: Single endpoint, two-step flow:
- **Step 1**: `{ username, password }` → verify Argon2id hash
- If TOTP not enabled: return `{ token: "jwt..." }`
- If TOTP enabled: return `{ totp_required: true, totp_token: "temporary-token" }`
- **Step 2** (only if TOTP): `{ totp_token, totp_code }` → verify TOTP code against stored secret
- `totp_token` is a short-lived JWT (60s, contains user_id, purpose="totp")
- If valid: return `{ token: "jwt..." }`
- JWT claims: `{ sub: user_id, role: "user"|"admin", exp: timestamp }` (HS256, 24h)
- Auth middleware extracts `Authorization: Bearer <token>`, injects user info into request extensions
- Default admin created at first startup (password from `ADMIN_PASSWORD` env var)
### Error Response Format
All API errors use a consistent envelope:
```json
{ "error": "Human-readable error message" }
```
With appropriate HTTP status codes: 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), 500 (internal).
### 5.2 Endpoints
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| /api/auth/login | POST | No | Login (+ optional TOTP) |
| /api/sensors | GET | Yes | List all sensors |
| /api/sensors | POST | Yes | Create sensor |
| /api/sensors/:id | GET | Yes | Sensor detail |
| /api/sensors/:id | PUT | Yes | Update sensor |
| /api/sensors/:id | DELETE | Yes | Delete sensor (resets topic to unapproved, in SQLite transaction) |
| /api/sensors/:id/data | GET | Yes | InfluxDB proxy, ?hours=N |
| /api/sensors/:id/display-config | GET | Yes | Get field config |
| /api/sensors/:id/display-config | PUT | Yes | Save field config |
| /api/topics | GET | Yes | List all topics |
| /api/topics/:id | PUT | Yes | Approve, assign, update |
| /api/topics/:id/threshold | PUT | Yes | Set alarm threshold |
| /api/alarms | GET | Yes | Alarm summary |
| /api/users | GET | Admin | List users |
| /api/users | POST | Admin | Create user |
| /api/users/:id | PUT | Admin | Update user |
| /api/users/:id | DELETE | Admin | Delete user |
| /api/users/:id/totp | POST | Admin | Enable 2FA (returns secret + QR URI) |
| /api/users/:id/totp | DELETE | Admin | Disable 2FA |
### 5.3 InfluxDB Proxy
- Sends Flux query to InfluxDB via reqwest
- Receives annotated CSV (after pivot)
- Parses CSV to JSON array server-side
- Returns JSON to frontend — no InfluxDB credentials exposed
---
## 6. Frontend Design
React 18 + TypeScript, Vite 6, Tailwind 4, Recharts.
### 6.1 Pages
1. **LoginPage**: Username/password form, conditional TOTP input step
2. **DashboardPage**: 4 status tiles (active/warning/alarm/unknown counts) + active alarm list
3. **SensorsPage**: Table (desktop) / cards (mobile), CRUD modals, DataModal with two tabs:
- "Diagramme": Charts per configured panels (D1-D8), time selector, RF metadata toggle
- "Einrichten": Per-field config (label, unit, chart type, panel assignment)
4. **UnknownTopicsPage**: List of unapproved topics, dropdown to assign to free sensors
- Sensor edit form shows alarm threshold in **minutes** (only when topic assigned); conversion to hours happens in the API
5. **AlarmsPage**: Active alarms and warnings list
6. **UsersPage** (admin only): User CRUD, 2FA enable/disable
### 6.2 Charts (Recharts)
- **Line chart**: Time series, `connectNulls` enabled
- **Bar chart (daily)**: Frontend aggregates raw data by date
- **Bar chart (weekly)**: Frontend aggregates by calendar week
- **Pie chart**: Percentage distribution
- Fields assigned to panels D1-D8; same panel number = same chart
- RF metadata (RSSI, SNR, DR) toggleable in line charts
- Time ranges: 1h, 6h, 24h, 7d, 30d, 90d
- Data table below charts: last 20 values with configured labels/units
### 6.3 Responsive Layout
- Desktop (`lg:` breakpoint, 1024px+): Static sidebar navigation
- Mobile: Top bar with hamburger icon → animated slide-in drawer
- Tables switch to card lists on mobile (`hidden sm:block` / `sm:hidden`)
- DataModal: Centered overlay on desktop, bottom-sheet on mobile
### 6.4 Auth & State
- React Context API for auth state
- localStorage: `loraweb_token`, `loraweb_user`
- Axios interceptor: 401 response → automatic logout + redirect to login
- All pages auto-refresh every 15 seconds
---
## 7. Infrastructure
### 7.1 Docker Compose
Project name: `loraweb`. Four services:
| Service | Image | Ports (host) | Volumes |
|---------|-------|-------------|---------|
| daemon | Custom (Rust) | 8080 | loraweb-data:/data |
| api | Custom (Rust) | — (internal) | loraweb-data:/data |
| frontend | Custom (nginx) | 80 | — |
| influxdb | influxdb:2.7 | — (internal) | loraweb-influx:/var/lib/influxdb2 |
All containers run as user `loraweb` (UID 1001).
### 7.2 Dockerfiles
**Rust (daemon + api)**: Multi-stage build
- Stage 1: `rust:1.88-slim``cargo fetch``cargo build --release`
- Stage 2: `debian:bookworm-slim` → copy binary, create non-root user
- Build context: project root (Cargo workspace)
**Frontend**: Multi-stage build
- Stage 1: `node:20-alpine``npm install --legacy-peer-deps``npm run build`
- Stage 2: `nginx:alpine` → copy dist + nginx.conf
### 7.3 nginx.conf
- `location /api/``proxy_pass http://api:3001/api/`
- `location /` → serve static files, `try_files $uri /index.html` (SPA fallback)
### 7.4 systemd
`deploy/loraweb.service`:
- `WorkingDirectory=/opt/loraweb`
- `ExecStart=/usr/bin/docker compose up`
- `ExecStop=/usr/bin/docker compose down`
- `Restart=always`
### 7.5 Secrets
All via `.env` file (not in git). `.env.example` as template with:
- `JWT_SECRET`, `ADMIN_PASSWORD`
- `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET`
### 7.6 InfluxDB Bootstrap
InfluxDB 2.7 supports automatic setup via environment variables in Docker:
- `DOCKER_INFLUXDB_INIT_MODE=setup`
- `DOCKER_INFLUXDB_INIT_USERNAME`, `DOCKER_INFLUXDB_INIT_PASSWORD`
- `DOCKER_INFLUXDB_INIT_ORG=loraweb`
- `DOCKER_INFLUXDB_INIT_BUCKET=sensors`
- `DOCKER_INFLUXDB_INIT_ADMIN_TOKEN` (same value as `INFLUX_TOKEN`)
This creates org, bucket, and admin token on first start. Subsequent starts skip setup if data directory exists.
### 7.7 CORS
In production, nginx proxies all `/api/` requests (same origin) — no CORS needed. For local development (Vite dev server on different port), the API includes permissive CORS headers (`tower-http` CorsLayer with `Any` origin). This matches the Pflichtenheft's known limitation E1.
---
## 8. Implementation Order (Bottom-Up)
1. Database migrations (SQL file + programmatic init)
2. Daemon (HTTP ingest + alarm monitor)
3. REST API (auth, CRUD, InfluxDB proxy)
4. Frontend (pages, charts, responsive layout)
5. Docker & infrastructure (Dockerfiles, Compose, nginx, systemd)
Each layer is testable independently before the next is built.