Based on Pflichtenheft v2.2, documents the full system design including daemon, API, frontend, and infrastructure with agreed deviations (Axum 0.8, SQLx 0.8, Vite 6, Tailwind 4). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Markdown
321 lines
12 KiB
Markdown
# 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
|
||
│ └── 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.
|
||
|
||
### 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
|
||
- Other events (join, ack, txack, log, status): return 200 OK, no processing
|
||
- InfluxDB errors: log 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`: Verify username/password (Argon2id)
|
||
- If TOTP enabled: return `{ totp_required: true }`
|
||
- Second request with TOTP code → issue JWT
|
||
- If no TOTP: issue JWT directly
|
||
- JWT: HS256, 24h validity, contains user id + role
|
||
- Auth middleware extracts Bearer token, injects user info
|
||
- Default admin created at first startup (password from env var)
|
||
|
||
### 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) |
|
||
| /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
|
||
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`
|
||
- `DOCKER_INFLUXDB_INIT_*` for InfluxDB bootstrap
|
||
|
||
---
|
||
|
||
## 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.
|