20 tasks across 5 phases: scaffolding, daemon, API, frontend, infrastructure. Fixed after review: TOTP secret decoding, InfluxDB writer uses reqwest directly (Line Protocol), daemon config path configurable, added missing deps (anyhow, rand), added TypeScript interfaces for chart config, added slide-in animation CSS, added package.json scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3319 lines
93 KiB
Markdown
3319 lines
93 KiB
Markdown
# LoRaWAN Web Portal – Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a complete LoRaWAN monitoring portal with Rust backend (daemon + API), React frontend, and Docker infrastructure.
|
||
|
||
**Architecture:** Bottom-up: DB schema → Daemon → API → Frontend → Docker. Rust Cargo workspace with two binaries (daemon, api). React 18 + Vite 6 + Tailwind 4 frontend. SQLite for config, InfluxDB for time series.
|
||
|
||
**Tech Stack:** Rust (Axum 0.8, SQLx 0.8, Tokio), React 18, TypeScript, Vite 6, Tailwind CSS 4, Recharts, Docker Compose, nginx, InfluxDB 2.7
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-03-19-loraweb-design.md`
|
||
|
||
---
|
||
|
||
## Phase 1: Project Scaffolding & Database
|
||
|
||
### Task 1: Cargo Workspace Setup
|
||
|
||
**Files:**
|
||
- Create: `Cargo.toml` (workspace root)
|
||
- Create: `backend/daemon/Cargo.toml`
|
||
- Create: `backend/daemon/src/main.rs`
|
||
- Create: `backend/api/Cargo.toml`
|
||
- Create: `backend/api/src/main.rs`
|
||
|
||
- [ ] **Step 1: Create workspace root Cargo.toml**
|
||
|
||
```toml
|
||
[workspace]
|
||
members = ["backend/daemon", "backend/api"]
|
||
resolver = "2"
|
||
```
|
||
|
||
- [ ] **Step 2: Create daemon Cargo.toml**
|
||
|
||
```toml
|
||
[package]
|
||
name = "loraweb-daemon"
|
||
version = "0.1.0"
|
||
edition = "2024"
|
||
|
||
[dependencies]
|
||
axum = "0.8"
|
||
tokio = { version = "1", features = ["full"] }
|
||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
config = "0.14"
|
||
reqwest = { version = "0.12", features = ["json"] }
|
||
chrono = { version = "0.4", features = ["serde"] }
|
||
uuid = { version = "1", features = ["v4"] }
|
||
anyhow = "1"
|
||
tracing = "0.1"
|
||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||
```
|
||
|
||
- [ ] **Step 3: Create daemon src/main.rs (hello world)**
|
||
|
||
```rust
|
||
#[tokio::main]
|
||
async fn main() {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||
.init();
|
||
tracing::info!("loraweb-daemon starting...");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create API Cargo.toml**
|
||
|
||
```toml
|
||
[package]
|
||
name = "loraweb-api"
|
||
version = "0.1.0"
|
||
edition = "2024"
|
||
|
||
[dependencies]
|
||
axum = "0.8"
|
||
tokio = { version = "1", features = ["full"] }
|
||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
reqwest = { version = "0.12", features = ["json"] }
|
||
jsonwebtoken = "9"
|
||
argon2 = "0.5"
|
||
totp-rs = { version = "5", features = ["gen_secret"] }
|
||
chrono = { version = "0.4", features = ["serde"] }
|
||
uuid = { version = "1", features = ["v4"] }
|
||
tracing = "0.1"
|
||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||
tower-http = { version = "0.6", features = ["cors"] }
|
||
anyhow = "1"
|
||
rand = "0.8"
|
||
```
|
||
|
||
- [ ] **Step 5: Create API src/main.rs (hello world)**
|
||
|
||
```rust
|
||
#[tokio::main]
|
||
async fn main() {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||
.init();
|
||
tracing::info!("loraweb-api starting...");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify workspace compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: Compiles both crates successfully.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add Cargo.toml backend/
|
||
git commit -m "feat: scaffold Cargo workspace with daemon and API crates"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Database Migration & Init
|
||
|
||
**Files:**
|
||
- Create: `database/migrations/001_initial.sql`
|
||
- Create: `backend/daemon/src/db.rs`
|
||
- Modify: `backend/daemon/src/main.rs`
|
||
|
||
- [ ] **Step 1: Write SQL migration file**
|
||
|
||
```sql
|
||
-- database/migrations/001_initial.sql
|
||
|
||
CREATE TABLE IF NOT EXISTS topics (
|
||
id TEXT PRIMARY KEY,
|
||
topic TEXT UNIQUE NOT NULL,
|
||
approved INTEGER NOT NULL DEFAULT 0,
|
||
last_seen TEXT,
|
||
alarm_status TEXT NOT NULL DEFAULT 'unknown',
|
||
alarm_threshold_hours REAL NOT NULL DEFAULT 0.0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS sensors (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
location TEXT NOT NULL DEFAULT '',
|
||
sensor_type TEXT NOT NULL DEFAULT '',
|
||
active INTEGER NOT NULL DEFAULT 1,
|
||
topic_id TEXT REFERENCES topics(id),
|
||
display_config TEXT NOT NULL DEFAULT '{}',
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'user',
|
||
totp_secret TEXT,
|
||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Write db.rs with init_db function**
|
||
|
||
```rust
|
||
// backend/daemon/src/db.rs
|
||
use sqlx::SqlitePool;
|
||
|
||
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||
sqlx::query("PRAGMA journal_mode=WAL").execute(pool).await?;
|
||
sqlx::query("PRAGMA busy_timeout=5000").execute(pool).await?;
|
||
|
||
let migration = include_str!("../../../database/migrations/001_initial.sql");
|
||
for statement in migration.split(';') {
|
||
let stmt = statement.trim();
|
||
if !stmt.is_empty() {
|
||
sqlx::query(stmt).execute(pool).await?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update daemon main.rs to connect and init DB**
|
||
|
||
```rust
|
||
// backend/daemon/src/main.rs
|
||
mod db;
|
||
|
||
use sqlx::sqlite::SqlitePoolOptions;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||
.init();
|
||
|
||
let db_path = std::env::var("DATABASE_PATH").unwrap_or_else(|_| "loraweb.db".into());
|
||
let pool = SqlitePoolOptions::new()
|
||
.max_connections(5)
|
||
.connect(&format!("sqlite:{}?mode=rwc", db_path))
|
||
.await?;
|
||
|
||
db::init_db(&pool).await?;
|
||
tracing::info!("Database initialized");
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
Add `anyhow = "1"` to daemon Cargo.toml dependencies.
|
||
|
||
- [ ] **Step 4: Write test for DB initialization**
|
||
|
||
Add to `backend/daemon/src/db.rs`:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use sqlx::SqlitePool;
|
||
|
||
#[tokio::test]
|
||
async fn test_init_db_creates_tables() {
|
||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||
init_db(&pool).await.unwrap();
|
||
|
||
// Verify tables exist
|
||
let topics: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(topics.0, 0);
|
||
|
||
let sensors: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sensors")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(sensors.0, 0);
|
||
|
||
let users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(users.0, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_init_db_is_idempotent() {
|
||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||
init_db(&pool).await.unwrap();
|
||
init_db(&pool).await.unwrap(); // should not error
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
Run: `cargo test -p loraweb-daemon`
|
||
Expected: 2 tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add database/ backend/daemon/
|
||
git commit -m "feat: add SQLite migration and DB init with WAL mode"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: Daemon
|
||
|
||
### Task 3: Daemon Configuration
|
||
|
||
**Files:**
|
||
- Create: `backend/daemon/src/config.rs`
|
||
- Create: `deploy/daemon-config.toml`
|
||
- Modify: `backend/daemon/src/main.rs`
|
||
- Modify: `backend/daemon/Cargo.toml`
|
||
|
||
- [ ] **Step 1: Create example config file**
|
||
|
||
```toml
|
||
# deploy/daemon-config.toml
|
||
[http]
|
||
bind = "0.0.0.0"
|
||
port = 8080
|
||
|
||
[influx]
|
||
url = "http://localhost:8086"
|
||
token = ""
|
||
org = "loraweb"
|
||
bucket = "sensors"
|
||
|
||
[database]
|
||
path = "loraweb.db"
|
||
|
||
[alarm]
|
||
warning_threshold_hours = 2.0
|
||
alarm_threshold_hours = 6.0
|
||
check_interval_secs = 60
|
||
```
|
||
|
||
- [ ] **Step 2: Write config.rs**
|
||
|
||
```rust
|
||
// backend/daemon/src/config.rs
|
||
use config::{Config, Environment, File};
|
||
use serde::Deserialize;
|
||
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct AppConfig {
|
||
pub http: HttpConfig,
|
||
pub influx: InfluxConfig,
|
||
pub database: DatabaseConfig,
|
||
pub alarm: AlarmConfig,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct HttpConfig {
|
||
#[serde(default = "default_bind")]
|
||
pub bind: String,
|
||
#[serde(default = "default_port")]
|
||
pub port: u16,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct InfluxConfig {
|
||
#[serde(default = "default_influx_url")]
|
||
pub url: String,
|
||
#[serde(default)]
|
||
pub token: String,
|
||
#[serde(default = "default_org")]
|
||
pub org: String,
|
||
#[serde(default = "default_bucket")]
|
||
pub bucket: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct DatabaseConfig {
|
||
#[serde(default = "default_db_path")]
|
||
pub path: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Clone)]
|
||
pub struct AlarmConfig {
|
||
#[serde(default = "default_warning")]
|
||
pub warning_threshold_hours: f64,
|
||
#[serde(default = "default_alarm")]
|
||
pub alarm_threshold_hours: f64,
|
||
#[serde(default = "default_interval")]
|
||
pub check_interval_secs: u64,
|
||
}
|
||
|
||
fn default_bind() -> String { "0.0.0.0".into() }
|
||
fn default_port() -> u16 { 8080 }
|
||
fn default_influx_url() -> String { "http://localhost:8086".into() }
|
||
fn default_org() -> String { "loraweb".into() }
|
||
fn default_bucket() -> String { "sensors".into() }
|
||
fn default_db_path() -> String { "loraweb.db".into() }
|
||
fn default_warning() -> f64 { 2.0 }
|
||
fn default_alarm() -> f64 { 6.0 }
|
||
fn default_interval() -> u64 { 60 }
|
||
|
||
impl AppConfig {
|
||
pub fn load() -> Result<Self, config::ConfigError> {
|
||
let config_path = std::env::var("CONFIG_PATH")
|
||
.unwrap_or_else(|_| "daemon-config".into());
|
||
let cfg = Config::builder()
|
||
.add_source(File::with_name(&config_path).required(false))
|
||
.add_source(Environment::with_prefix("LORAWEB").separator("__"))
|
||
.build()?;
|
||
cfg.try_deserialize()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update main.rs to load config**
|
||
|
||
```rust
|
||
mod config;
|
||
mod db;
|
||
|
||
use crate::config::AppConfig;
|
||
use sqlx::sqlite::SqlitePoolOptions;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive("loraweb_daemon=info".parse()?)
|
||
)
|
||
.init();
|
||
|
||
let cfg = AppConfig::load()?;
|
||
tracing::info!("Configuration loaded");
|
||
|
||
let pool = SqlitePoolOptions::new()
|
||
.max_connections(5)
|
||
.connect(&format!("sqlite:{}?mode=rwc", cfg.database.path))
|
||
.await?;
|
||
db::init_db(&pool).await?;
|
||
|
||
tracing::info!(
|
||
"LoRaWAN Daemon ready — Ingest: http://{}:{}/",
|
||
cfg.http.bind, cfg.http.port
|
||
);
|
||
tracing::info!(
|
||
"Configure ChirpStack HTTP Integration: POST http://<this-server>:{}/ ",
|
||
cfg.http.port
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify it compiles and runs**
|
||
|
||
Run: `cargo build -p loraweb-daemon`
|
||
Expected: Compiles. Running prints banner and exits.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/daemon/ deploy/daemon-config.toml
|
||
git commit -m "feat(daemon): add configuration via config crate with TOML + env vars"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: HTTP Ingest Listener
|
||
|
||
**Files:**
|
||
- Create: `backend/daemon/src/http.rs`
|
||
- Create: `backend/daemon/src/influx.rs`
|
||
- Modify: `backend/daemon/src/db.rs` (add register_topic)
|
||
- Modify: `backend/daemon/src/main.rs`
|
||
|
||
- [ ] **Step 1: Add topic registration to db.rs**
|
||
|
||
Append to `backend/daemon/src/db.rs` (before `#[cfg(test)]`):
|
||
|
||
```rust
|
||
use chrono::Utc;
|
||
use uuid::Uuid;
|
||
|
||
pub async fn register_topic(pool: &SqlitePool, dev_eui: &str) -> Result<(), sqlx::Error> {
|
||
let now = Utc::now().to_rfc3339();
|
||
let id = Uuid::new_v4().to_string();
|
||
sqlx::query(
|
||
"INSERT OR IGNORE INTO topics (id, topic, approved, alarm_status, alarm_threshold_hours, created_at) \
|
||
VALUES (?, ?, 0, 'unknown', 0.0, ?)"
|
||
)
|
||
.bind(&id)
|
||
.bind(dev_eui)
|
||
.bind(&now)
|
||
.execute(pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn update_last_seen(pool: &SqlitePool, dev_eui: &str, timestamp: &str) -> Result<(), sqlx::Error> {
|
||
sqlx::query("UPDATE topics SET last_seen = ? WHERE topic = ?")
|
||
.bind(timestamp)
|
||
.bind(dev_eui)
|
||
.execute(pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write tests for topic registration**
|
||
|
||
Add to db.rs tests module:
|
||
|
||
```rust
|
||
#[tokio::test]
|
||
async fn test_register_topic() {
|
||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||
init_db(&pool).await.unwrap();
|
||
|
||
register_topic(&pool, "abcdef1234567890").await.unwrap();
|
||
|
||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics WHERE topic = ?")
|
||
.bind("abcdef1234567890")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(count.0, 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_register_topic_idempotent() {
|
||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||
init_db(&pool).await.unwrap();
|
||
|
||
register_topic(&pool, "abcdef1234567890").await.unwrap();
|
||
register_topic(&pool, "abcdef1234567890").await.unwrap();
|
||
|
||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(count.0, 1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p loraweb-daemon`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 4: Write influx.rs (using reqwest directly for InfluxDB Line Protocol)**
|
||
|
||
```rust
|
||
// backend/daemon/src/influx.rs
|
||
use serde_json::Value;
|
||
use chrono::DateTime;
|
||
|
||
pub struct InfluxWriter {
|
||
client: reqwest::Client,
|
||
url: String,
|
||
token: String,
|
||
org: String,
|
||
bucket: String,
|
||
}
|
||
|
||
impl InfluxWriter {
|
||
pub fn new(url: &str, token: &str, org: &str, bucket: &str) -> Self {
|
||
Self {
|
||
client: reqwest::Client::new(),
|
||
url: url.to_string(),
|
||
token: token.to_string(),
|
||
org: org.to_string(),
|
||
bucket: bucket.to_string(),
|
||
}
|
||
}
|
||
|
||
pub async fn write_uplink(
|
||
&self,
|
||
dev_eui: &str,
|
||
device_name: &str,
|
||
application_name: &str,
|
||
timestamp: &str,
|
||
object: &serde_json::Map<String, Value>,
|
||
rssi: Option<f64>,
|
||
snr: Option<f64>,
|
||
dr: Option<i64>,
|
||
f_cnt: Option<i64>,
|
||
f_port: Option<i64>,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
let ts_nanos = DateTime::parse_from_rfc3339(timestamp)?
|
||
.timestamp_nanos_opt()
|
||
.unwrap_or(0);
|
||
|
||
// Build InfluxDB Line Protocol string
|
||
// Format: measurement,tag1=val1,tag2=val2 field1=val1,field2=val2 timestamp
|
||
let tags = format!(
|
||
"sensor_data,dev_eui={},device_name={},application_name={}",
|
||
escape_tag(dev_eui),
|
||
escape_tag(device_name),
|
||
escape_tag(application_name),
|
||
);
|
||
|
||
let mut fields = Vec::new();
|
||
|
||
// Dynamic object fields
|
||
for (key, val) in object {
|
||
match val {
|
||
Value::Number(n) => {
|
||
if let Some(f) = n.as_f64() {
|
||
fields.push(format!("{}={}", escape_tag(key), f));
|
||
}
|
||
}
|
||
Value::Bool(b) => {
|
||
fields.push(format!("{}={}", escape_tag(key), b));
|
||
}
|
||
Value::String(s) => {
|
||
fields.push(format!("{}=\"{}\"", escape_tag(key), escape_field_value(s)));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// RF metadata
|
||
if let Some(v) = rssi { fields.push(format!("rssi={}", v)); }
|
||
if let Some(v) = snr { fields.push(format!("snr={}", v)); }
|
||
if let Some(v) = dr { fields.push(format!("dr={}i", v)); }
|
||
if let Some(v) = f_cnt { fields.push(format!("f_cnt={}i", v)); }
|
||
if let Some(v) = f_port { fields.push(format!("f_port={}i", v)); }
|
||
|
||
if fields.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
let line = format!("{} {} {}", tags, fields.join(","), ts_nanos);
|
||
|
||
let resp = self.client
|
||
.post(format!("{}/api/v2/write?org={}&bucket={}&precision=ns",
|
||
self.url, self.org, self.bucket))
|
||
.header("Authorization", format!("Token {}", self.token))
|
||
.header("Content-Type", "text/plain")
|
||
.body(line)
|
||
.send()
|
||
.await?;
|
||
|
||
if !resp.status().is_success() {
|
||
let status = resp.status();
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Err(format!("InfluxDB write failed ({}): {}", status, body).into());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn escape_tag(s: &str) -> String {
|
||
s.replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ")
|
||
}
|
||
|
||
fn escape_field_value(s: &str) -> String {
|
||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write http.rs**
|
||
|
||
```rust
|
||
// backend/daemon/src/http.rs
|
||
use axum::{
|
||
extract::{Query, State},
|
||
http::StatusCode,
|
||
response::IntoResponse,
|
||
routing::post,
|
||
Json, Router,
|
||
};
|
||
use serde::Deserialize;
|
||
use serde_json::Value;
|
||
use sqlx::SqlitePool;
|
||
use std::sync::Arc;
|
||
use crate::influx::InfluxWriter;
|
||
|
||
pub struct IngestState {
|
||
pub pool: SqlitePool,
|
||
pub influx: InfluxWriter,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct EventQuery {
|
||
pub event: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChirpStackUplink {
|
||
time: Option<String>,
|
||
#[serde(rename = "deviceInfo")]
|
||
device_info: Option<DeviceInfo>,
|
||
dr: Option<i64>,
|
||
#[serde(rename = "fCnt")]
|
||
f_cnt: Option<i64>,
|
||
#[serde(rename = "fPort")]
|
||
f_port: Option<i64>,
|
||
#[serde(rename = "rxInfo")]
|
||
rx_info: Option<Vec<RxInfo>>,
|
||
object: Option<serde_json::Map<String, Value>>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct DeviceInfo {
|
||
#[serde(rename = "devEui")]
|
||
dev_eui: String,
|
||
#[serde(rename = "deviceName")]
|
||
device_name: Option<String>,
|
||
#[serde(rename = "applicationName")]
|
||
application_name: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct RxInfo {
|
||
rssi: Option<f64>,
|
||
snr: Option<f64>,
|
||
}
|
||
|
||
pub fn router(state: Arc<IngestState>) -> Router {
|
||
Router::new()
|
||
.route("/", post(handle_event))
|
||
.with_state(state)
|
||
}
|
||
|
||
async fn handle_event(
|
||
State(state): State<Arc<IngestState>>,
|
||
Query(query): Query<EventQuery>,
|
||
Json(body): Json<Value>,
|
||
) -> impl IntoResponse {
|
||
let event = query.event.unwrap_or_default();
|
||
|
||
if event != "up" {
|
||
tracing::debug!(event = %event, "Non-uplink event received, ignoring");
|
||
return StatusCode::OK;
|
||
}
|
||
|
||
let uplink: ChirpStackUplink = match serde_json::from_value(body) {
|
||
Ok(u) => u,
|
||
Err(e) => {
|
||
tracing::warn!("Failed to parse uplink JSON: {}", e);
|
||
return StatusCode::BAD_REQUEST;
|
||
}
|
||
};
|
||
|
||
let device_info = match uplink.device_info {
|
||
Some(di) => di,
|
||
None => {
|
||
tracing::warn!("Uplink missing deviceInfo");
|
||
return StatusCode::BAD_REQUEST;
|
||
}
|
||
};
|
||
|
||
let dev_eui = &device_info.dev_eui;
|
||
let device_name = device_info.device_name.as_deref().unwrap_or("");
|
||
let app_name = device_info.application_name.as_deref().unwrap_or("");
|
||
let timestamp = uplink.time.as_deref().unwrap_or("");
|
||
|
||
// Register topic in SQLite (idempotent)
|
||
if let Err(e) = crate::db::register_topic(&state.pool, dev_eui).await {
|
||
tracing::warn!("Failed to register topic {}: {}", dev_eui, e);
|
||
}
|
||
|
||
// Update last_seen
|
||
if !timestamp.is_empty() {
|
||
if let Err(e) = crate::db::update_last_seen(&state.pool, dev_eui, timestamp).await {
|
||
tracing::warn!("Failed to update last_seen for {}: {}", dev_eui, e);
|
||
}
|
||
}
|
||
|
||
// Write to InfluxDB
|
||
if let Some(object) = &uplink.object {
|
||
let rx = uplink.rx_info.as_ref().and_then(|r| r.first());
|
||
let rssi = rx.and_then(|r| r.rssi);
|
||
let snr = rx.and_then(|r| r.snr);
|
||
|
||
if let Err(e) = state.influx.write_uplink(
|
||
dev_eui, device_name, app_name, timestamp,
|
||
object, rssi, snr, uplink.dr, uplink.f_cnt, uplink.f_port,
|
||
).await {
|
||
tracing::warn!("InfluxDB write failed for {}: {}", dev_eui, e);
|
||
}
|
||
}
|
||
|
||
StatusCode::OK
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Update main.rs to start HTTP server**
|
||
|
||
```rust
|
||
mod config;
|
||
mod db;
|
||
mod http;
|
||
mod influx;
|
||
|
||
use crate::config::AppConfig;
|
||
use crate::http::IngestState;
|
||
use crate::influx::InfluxWriter;
|
||
use sqlx::sqlite::SqlitePoolOptions;
|
||
use std::sync::Arc;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive("loraweb_daemon=info".parse()?)
|
||
)
|
||
.init();
|
||
|
||
let cfg = AppConfig::load()?;
|
||
|
||
let pool = SqlitePoolOptions::new()
|
||
.max_connections(5)
|
||
.connect(&format!("sqlite:{}?mode=rwc", cfg.database.path))
|
||
.await?;
|
||
db::init_db(&pool).await?;
|
||
|
||
let influx = InfluxWriter::new(
|
||
&cfg.influx.url, &cfg.influx.token,
|
||
&cfg.influx.org, &cfg.influx.bucket,
|
||
);
|
||
|
||
let state = Arc::new(IngestState { pool, influx });
|
||
let app = http::router(state);
|
||
|
||
let addr = format!("{}:{}", cfg.http.bind, cfg.http.port);
|
||
tracing::info!("LoRaWAN Daemon ready — Ingest: http://{}/", addr);
|
||
tracing::info!(
|
||
"Configure ChirpStack HTTP Integration: POST http://<this-server>:{}/",
|
||
cfg.http.port
|
||
);
|
||
|
||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||
axum::serve(listener, app).await?;
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-daemon`
|
||
Expected: Compiles successfully.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add backend/daemon/
|
||
git commit -m "feat(daemon): add HTTP ingest listener with ChirpStack JSON parsing and InfluxDB writer"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Alarm Monitor
|
||
|
||
**Files:**
|
||
- Create: `backend/daemon/src/alarm.rs`
|
||
- Modify: `backend/daemon/src/main.rs` (spawn alarm task)
|
||
|
||
- [ ] **Step 1: Write alarm.rs**
|
||
|
||
```rust
|
||
// backend/daemon/src/alarm.rs
|
||
use sqlx::SqlitePool;
|
||
use chrono::Utc;
|
||
use std::time::Duration;
|
||
use tokio::time;
|
||
|
||
pub async fn run_alarm_monitor(
|
||
pool: SqlitePool,
|
||
warning_hours: f64,
|
||
alarm_hours: f64,
|
||
interval_secs: u64,
|
||
) {
|
||
let mut interval = time::interval(Duration::from_secs(interval_secs));
|
||
tracing::info!(
|
||
"Alarm monitor started (warning: {}h, alarm: {}h, interval: {}s)",
|
||
warning_hours, alarm_hours, interval_secs
|
||
);
|
||
|
||
loop {
|
||
interval.tick().await;
|
||
if let Err(e) = check_alarms(&pool, warning_hours, alarm_hours).await {
|
||
tracing::warn!("Alarm check failed: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub async fn check_alarms(
|
||
pool: &SqlitePool,
|
||
global_warning_hours: f64,
|
||
global_alarm_hours: f64,
|
||
) -> Result<(), sqlx::Error> {
|
||
let topics: Vec<(String, Option<String>, f64, String)> = sqlx::query_as(
|
||
"SELECT id, last_seen, alarm_threshold_hours, alarm_status FROM topics WHERE approved = 1"
|
||
)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let now = Utc::now();
|
||
|
||
for (id, last_seen, threshold_hours, current_status) in topics {
|
||
let new_status = match last_seen {
|
||
None => "unknown".to_string(),
|
||
Some(ref ts) => {
|
||
let alarm_h = if threshold_hours > 0.0 { threshold_hours } else { global_alarm_hours };
|
||
let warning_h = if threshold_hours > 0.0 {
|
||
threshold_hours / 3.0 // warning at 1/3 of alarm threshold
|
||
} else {
|
||
global_warning_hours
|
||
};
|
||
|
||
match chrono::DateTime::parse_from_rfc3339(ts) {
|
||
Ok(last) => {
|
||
let hours = (now - last.with_timezone(&Utc))
|
||
.num_minutes() as f64 / 60.0;
|
||
if hours < warning_h {
|
||
"active".to_string()
|
||
} else if hours < alarm_h {
|
||
"warning".to_string()
|
||
} else {
|
||
"alarm".to_string()
|
||
}
|
||
}
|
||
Err(_) => "unknown".to_string(),
|
||
}
|
||
}
|
||
};
|
||
|
||
if new_status != current_status {
|
||
tracing::info!(
|
||
"Topic {} status: {} -> {}",
|
||
id, current_status, new_status
|
||
);
|
||
sqlx::query("UPDATE topics SET alarm_status = ? WHERE id = ?")
|
||
.bind(&new_status)
|
||
.bind(&id)
|
||
.execute(pool)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::db;
|
||
|
||
async fn setup_db() -> SqlitePool {
|
||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||
db::init_db(&pool).await.unwrap();
|
||
pool
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_alarm_no_last_seen_is_unknown() {
|
||
let pool = setup_db().await;
|
||
sqlx::query(
|
||
"INSERT INTO topics (id, topic, approved, alarm_status, alarm_threshold_hours, created_at) \
|
||
VALUES ('t1', 'dev1', 1, 'active', 0.0, '2026-01-01T00:00:00Z')"
|
||
).execute(&pool).await.unwrap();
|
||
|
||
check_alarms(&pool, 2.0, 6.0).await.unwrap();
|
||
|
||
let status: (String,) = sqlx::query_as("SELECT alarm_status FROM topics WHERE id = 't1'")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(status.0, "unknown");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_alarm_recent_is_active() {
|
||
let pool = setup_db().await;
|
||
let recent = Utc::now().to_rfc3339();
|
||
sqlx::query(
|
||
"INSERT INTO topics (id, topic, approved, last_seen, alarm_status, alarm_threshold_hours, created_at) \
|
||
VALUES ('t1', 'dev1', 1, ?, 'unknown', 0.0, '2026-01-01T00:00:00Z')"
|
||
).bind(&recent).execute(&pool).await.unwrap();
|
||
|
||
check_alarms(&pool, 2.0, 6.0).await.unwrap();
|
||
|
||
let status: (String,) = sqlx::query_as("SELECT alarm_status FROM topics WHERE id = 't1'")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(status.0, "active");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_alarm_old_is_alarm() {
|
||
let pool = setup_db().await;
|
||
let old = (Utc::now() - chrono::Duration::hours(10)).to_rfc3339();
|
||
sqlx::query(
|
||
"INSERT INTO topics (id, topic, approved, last_seen, alarm_status, alarm_threshold_hours, created_at) \
|
||
VALUES ('t1', 'dev1', 1, ?, 'active', 0.0, '2026-01-01T00:00:00Z')"
|
||
).bind(&old).execute(&pool).await.unwrap();
|
||
|
||
check_alarms(&pool, 2.0, 6.0).await.unwrap();
|
||
|
||
let status: (String,) = sqlx::query_as("SELECT alarm_status FROM topics WHERE id = 't1'")
|
||
.fetch_one(&pool).await.unwrap();
|
||
assert_eq!(status.0, "alarm");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update main.rs to spawn alarm task**
|
||
|
||
Add before the `axum::serve` line:
|
||
|
||
```rust
|
||
let alarm_pool = pool.clone();
|
||
let alarm_cfg = cfg.alarm.clone();
|
||
tokio::spawn(async move {
|
||
crate::alarm::run_alarm_monitor(
|
||
alarm_pool,
|
||
alarm_cfg.warning_threshold_hours,
|
||
alarm_cfg.alarm_threshold_hours,
|
||
alarm_cfg.check_interval_secs,
|
||
).await;
|
||
});
|
||
```
|
||
|
||
Note: `pool` needs to be cloned before it's moved into `Arc<IngestState>`. Restructure: clone pool for alarm before creating state.
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p loraweb-daemon`
|
||
Expected: All tests pass (DB tests + alarm tests).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/daemon/
|
||
git commit -m "feat(daemon): add alarm monitor with configurable thresholds and status checks"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3: REST API
|
||
|
||
### Task 6: API Scaffolding & DB Init
|
||
|
||
**Files:**
|
||
- Create: `backend/api/src/db.rs` (reuse migration from daemon)
|
||
- Modify: `backend/api/src/main.rs`
|
||
|
||
- [ ] **Step 1: Create api/src/db.rs**
|
||
|
||
Same `init_db` function as daemon, but also creates default admin user:
|
||
|
||
```rust
|
||
// backend/api/src/db.rs
|
||
use sqlx::SqlitePool;
|
||
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
|
||
use rand::rngs::OsRng;
|
||
use chrono::Utc;
|
||
|
||
pub async fn init_db(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
|
||
sqlx::query("PRAGMA journal_mode=WAL").execute(pool).await?;
|
||
sqlx::query("PRAGMA busy_timeout=5000").execute(pool).await?;
|
||
|
||
let migration = include_str!("../../../database/migrations/001_initial.sql");
|
||
for statement in migration.split(';') {
|
||
let stmt = statement.trim();
|
||
if !stmt.is_empty() {
|
||
sqlx::query(stmt).execute(pool).await?;
|
||
}
|
||
}
|
||
|
||
// Create default admin if no users exist
|
||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||
.fetch_one(pool).await?;
|
||
|
||
if count.0 == 0 {
|
||
let password = std::env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".into());
|
||
let salt = SaltString::generate(&mut OsRng);
|
||
let hash = Argon2::default()
|
||
.hash_password(password.as_bytes(), &salt)?
|
||
.to_string();
|
||
let now = Utc::now().to_rfc3339();
|
||
|
||
sqlx::query(
|
||
"INSERT INTO users (username, password_hash, role, totp_enabled, created_at) \
|
||
VALUES ('admin', ?, 'admin', 0, ?)"
|
||
)
|
||
.bind(&hash)
|
||
.bind(&now)
|
||
.execute(pool)
|
||
.await?;
|
||
tracing::info!("Default admin user created");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
Add `rand = "0.8"` to api Cargo.toml.
|
||
|
||
- [ ] **Step 2: Set up API main.rs with router skeleton**
|
||
|
||
```rust
|
||
// backend/api/src/main.rs
|
||
mod db;
|
||
mod auth;
|
||
mod sensors;
|
||
mod topics;
|
||
mod alarms;
|
||
mod users;
|
||
|
||
use axum::Router;
|
||
use sqlx::sqlite::SqlitePoolOptions;
|
||
use std::sync::Arc;
|
||
use tower_http::cors::{CorsLayer, Any};
|
||
|
||
#[derive(Clone)]
|
||
pub struct AppState {
|
||
pub pool: sqlx::SqlitePool,
|
||
pub jwt_secret: String,
|
||
pub influx_url: String,
|
||
pub influx_token: String,
|
||
pub influx_org: String,
|
||
pub influx_bucket: String,
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive("loraweb_api=info".parse()?)
|
||
)
|
||
.init();
|
||
|
||
let db_path = std::env::var("DATABASE_PATH").unwrap_or_else(|_| "/data/loraweb.db".into());
|
||
let port = std::env::var("PORT").unwrap_or_else(|_| "3001".into());
|
||
|
||
let pool = SqlitePoolOptions::new()
|
||
.max_connections(10)
|
||
.connect(&format!("sqlite:{}?mode=rwc", db_path))
|
||
.await?;
|
||
db::init_db(&pool).await?;
|
||
|
||
let state = AppState {
|
||
pool,
|
||
jwt_secret: std::env::var("JWT_SECRET").unwrap_or_else(|_| "insecure-dev-secret".into()),
|
||
influx_url: std::env::var("INFLUX_URL").unwrap_or_else(|_| "http://influxdb:8086".into()),
|
||
influx_token: std::env::var("INFLUX_TOKEN").unwrap_or_default(),
|
||
influx_org: std::env::var("INFLUX_ORG").unwrap_or_else(|_| "loraweb".into()),
|
||
influx_bucket: std::env::var("INFLUX_BUCKET").unwrap_or_else(|_| "sensors".into()),
|
||
};
|
||
|
||
let cors = CorsLayer::new()
|
||
.allow_origin(Any)
|
||
.allow_methods(Any)
|
||
.allow_headers(Any);
|
||
|
||
let app = Router::new()
|
||
.nest("/api", api_routes(state))
|
||
.layer(cors);
|
||
|
||
let addr = format!("0.0.0.0:{}", port);
|
||
tracing::info!("LoRaWEB API listening on {}", addr);
|
||
|
||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||
axum::serve(listener, app).await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn api_routes(state: AppState) -> Router {
|
||
Router::new()
|
||
.merge(auth::routes())
|
||
.merge(sensors::routes())
|
||
.merge(topics::routes())
|
||
.merge(alarms::routes())
|
||
.merge(users::routes())
|
||
.with_state(state)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create empty route modules**
|
||
|
||
Create stub files for `auth.rs`, `sensors.rs`, `topics.rs`, `alarms.rs`, `users.rs`:
|
||
|
||
```rust
|
||
// backend/api/src/auth.rs (and similar for others)
|
||
use axum::Router;
|
||
use crate::AppState;
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-api`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/api/
|
||
git commit -m "feat(api): scaffold API with router, DB init, default admin, CORS"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: JWT Authentication
|
||
|
||
**Files:**
|
||
- Modify: `backend/api/src/auth.rs`
|
||
|
||
- [ ] **Step 1: Implement auth.rs with login + JWT middleware**
|
||
|
||
```rust
|
||
// backend/api/src/auth.rs
|
||
use axum::{
|
||
extract::State,
|
||
http::{Request, StatusCode},
|
||
middleware::Next,
|
||
response::{IntoResponse, Response},
|
||
routing::post,
|
||
Json, Router,
|
||
};
|
||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||
use serde::{Deserialize, Serialize};
|
||
use chrono::{Utc, Duration};
|
||
use crate::AppState;
|
||
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct Claims {
|
||
pub sub: i64, // user id
|
||
pub role: String,
|
||
pub exp: i64,
|
||
#[serde(default)]
|
||
pub purpose: Option<String>, // "totp" for temporary token
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct LoginRequest {
|
||
username: String,
|
||
password: String,
|
||
totp_token: Option<String>,
|
||
totp_code: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct LoginResponse {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
token: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
totp_required: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
totp_token: Option<String>,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/auth/login", post(login))
|
||
}
|
||
|
||
async fn login(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<LoginRequest>,
|
||
) -> Result<Json<LoginResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||
let err = |msg: &str| (
|
||
StatusCode::UNAUTHORIZED,
|
||
Json(serde_json::json!({ "error": msg })),
|
||
);
|
||
|
||
// Step 2: TOTP verification
|
||
if let (Some(totp_token), Some(totp_code)) = (&req.totp_token, &req.totp_code) {
|
||
let claims = decode_jwt(&state.jwt_secret, totp_token)
|
||
.map_err(|_| err("Invalid TOTP token"))?;
|
||
|
||
if claims.purpose.as_deref() != Some("totp") {
|
||
return Err(err("Invalid token purpose"));
|
||
}
|
||
|
||
let user: Option<(i64, String, Option<String>)> = sqlx::query_as(
|
||
"SELECT id, role, totp_secret FROM users WHERE id = ?"
|
||
)
|
||
.bind(claims.sub)
|
||
.fetch_optional(&state.pool)
|
||
.await
|
||
.map_err(|_| err("Database error"))?;
|
||
|
||
let (user_id, role, totp_secret) = user.ok_or_else(|| err("User not found"))?;
|
||
let secret = totp_secret.ok_or_else(|| err("TOTP not configured"))?;
|
||
|
||
let secret_bytes = totp_rs::Secret::Encoded(secret.clone())
|
||
.to_bytes()
|
||
.map_err(|_| err("TOTP secret decode failed"))?;
|
||
let totp = totp_rs::TOTP::new(
|
||
totp_rs::Algorithm::SHA1, 6, 1, 30, secret_bytes,
|
||
).map_err(|_| err("TOTP init failed"))?;
|
||
|
||
if !totp.check_current(totp_code).map_err(|_| err("TOTP check failed"))? {
|
||
return Err(err("Invalid TOTP code"));
|
||
}
|
||
|
||
let token = create_jwt(&state.jwt_secret, user_id, &role, None)?;
|
||
return Ok(Json(LoginResponse {
|
||
token: Some(token),
|
||
totp_required: None,
|
||
totp_token: None,
|
||
}));
|
||
}
|
||
|
||
// Step 1: Password verification
|
||
let user: Option<(i64, String, String, i32)> = sqlx::query_as(
|
||
"SELECT id, password_hash, role, totp_enabled FROM users WHERE username = ?"
|
||
)
|
||
.bind(&req.username)
|
||
.fetch_optional(&state.pool)
|
||
.await
|
||
.map_err(|_| err("Database error"))?;
|
||
|
||
let (user_id, hash, role, totp_enabled) = user.ok_or_else(|| err("Invalid credentials"))?;
|
||
|
||
let parsed_hash = PasswordHash::new(&hash).map_err(|_| err("Invalid credentials"))?;
|
||
Argon2::default()
|
||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||
.map_err(|_| err("Invalid credentials"))?;
|
||
|
||
if totp_enabled != 0 {
|
||
let totp_token = create_jwt(&state.jwt_secret, user_id, &role, Some("totp"))?;
|
||
return Ok(Json(LoginResponse {
|
||
token: None,
|
||
totp_required: Some(true),
|
||
totp_token: Some(totp_token),
|
||
}));
|
||
}
|
||
|
||
let token = create_jwt(&state.jwt_secret, user_id, &role, None)?;
|
||
Ok(Json(LoginResponse {
|
||
token: Some(token),
|
||
totp_required: None,
|
||
totp_token: None,
|
||
}))
|
||
}
|
||
|
||
fn create_jwt(
|
||
secret: &str, user_id: i64, role: &str, purpose: Option<&str>,
|
||
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
|
||
let exp = if purpose.is_some() {
|
||
Utc::now() + Duration::seconds(60) // TOTP token: 60s
|
||
} else {
|
||
Utc::now() + Duration::hours(24) // Session token: 24h
|
||
};
|
||
|
||
let claims = Claims {
|
||
sub: user_id,
|
||
role: role.to_string(),
|
||
exp: exp.timestamp(),
|
||
purpose: purpose.map(String::from),
|
||
};
|
||
|
||
encode(
|
||
&Header::default(),
|
||
&claims,
|
||
&EncodingKey::from_secret(secret.as_bytes()),
|
||
).map_err(|_| (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": "Failed to create token" })),
|
||
))
|
||
}
|
||
|
||
fn decode_jwt(secret: &str, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||
let data = decode::<Claims>(
|
||
token,
|
||
&DecodingKey::from_secret(secret.as_bytes()),
|
||
&Validation::default(),
|
||
)?;
|
||
Ok(data.claims)
|
||
}
|
||
|
||
/// Middleware to require authentication
|
||
pub async fn require_auth(
|
||
State(state): State<AppState>,
|
||
mut req: Request<axum::body::Body>,
|
||
next: Next,
|
||
) -> Response {
|
||
let auth_header = req.headers()
|
||
.get("authorization")
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.strip_prefix("Bearer "));
|
||
|
||
let token = match auth_header {
|
||
Some(t) => t,
|
||
None => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Missing token" }))).into_response(),
|
||
};
|
||
|
||
let claims = match decode_jwt(&state.jwt_secret, token) {
|
||
Ok(c) => c,
|
||
Err(_) => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Invalid token" }))).into_response(),
|
||
};
|
||
|
||
if claims.purpose.is_some() {
|
||
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Invalid token type" }))).into_response();
|
||
}
|
||
|
||
req.extensions_mut().insert(claims);
|
||
next.run(req).await
|
||
}
|
||
|
||
/// Middleware to require admin role
|
||
pub async fn require_admin(
|
||
req: Request<axum::body::Body>,
|
||
next: Next,
|
||
) -> Response {
|
||
let claims = req.extensions().get::<Claims>();
|
||
match claims {
|
||
Some(c) if c.role == "admin" => next.run(req).await,
|
||
_ => (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin required" }))).into_response(),
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-api`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/api/src/auth.rs
|
||
git commit -m "feat(api): implement JWT auth with Argon2 password verification and TOTP two-step flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Sensor CRUD & InfluxDB Proxy
|
||
|
||
**Files:**
|
||
- Modify: `backend/api/src/sensors.rs`
|
||
|
||
- [ ] **Step 1: Implement sensors.rs**
|
||
|
||
```rust
|
||
// backend/api/src/sensors.rs
|
||
use axum::{
|
||
extract::{Path, Query, State},
|
||
http::StatusCode,
|
||
middleware,
|
||
routing::{delete, get, put},
|
||
Json, Router,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use chrono::Utc;
|
||
use uuid::Uuid;
|
||
use crate::{auth, AppState};
|
||
|
||
#[derive(Serialize, sqlx::FromRow)]
|
||
pub struct Sensor {
|
||
id: String,
|
||
name: String,
|
||
description: String,
|
||
location: String,
|
||
sensor_type: String,
|
||
active: i32,
|
||
topic_id: Option<String>,
|
||
display_config: String,
|
||
created_at: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateSensor {
|
||
name: String,
|
||
description: Option<String>,
|
||
location: Option<String>,
|
||
sensor_type: Option<String>,
|
||
topic_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateSensor {
|
||
name: Option<String>,
|
||
description: Option<String>,
|
||
location: Option<String>,
|
||
sensor_type: Option<String>,
|
||
active: Option<bool>,
|
||
topic_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct DataQuery {
|
||
hours: Option<f64>,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/sensors", get(list_sensors).post(create_sensor))
|
||
.route("/sensors/{id}", get(get_sensor).put(update_sensor).delete(delete_sensor))
|
||
.route("/sensors/{id}/data", get(get_sensor_data))
|
||
.route("/sensors/{id}/display-config", get(get_display_config).put(save_display_config))
|
||
.route_layer(middleware::from_fn_with_state(
|
||
// Note: state needs to be passed; will be wired in main.rs
|
||
AppState::default_placeholder(), auth::require_auth,
|
||
))
|
||
}
|
||
```
|
||
|
||
Wait — the middleware approach with state in Axum 0.8 needs careful wiring. Let me restructure the routes to use a simpler pattern.
|
||
|
||
**Revised approach:** Apply auth middleware at the router level in main.rs instead of per-module. This is cleaner.
|
||
|
||
```rust
|
||
// backend/api/src/sensors.rs
|
||
use axum::{
|
||
extract::{Path, Query, State},
|
||
http::StatusCode,
|
||
routing::{delete, get, put},
|
||
Json, Router,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use chrono::Utc;
|
||
use uuid::Uuid;
|
||
use crate::AppState;
|
||
|
||
#[derive(Serialize, sqlx::FromRow)]
|
||
pub struct Sensor {
|
||
id: String,
|
||
name: String,
|
||
description: String,
|
||
location: String,
|
||
sensor_type: String,
|
||
active: i32,
|
||
topic_id: Option<String>,
|
||
display_config: String,
|
||
created_at: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateSensor {
|
||
name: String,
|
||
#[serde(default)]
|
||
description: String,
|
||
#[serde(default)]
|
||
location: String,
|
||
#[serde(default)]
|
||
sensor_type: String,
|
||
topic_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateSensor {
|
||
name: Option<String>,
|
||
description: Option<String>,
|
||
location: Option<String>,
|
||
sensor_type: Option<String>,
|
||
active: Option<bool>,
|
||
topic_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct DataQuery {
|
||
hours: Option<f64>,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/sensors", get(list_sensors).post(create_sensor))
|
||
.route("/sensors/{id}", get(get_sensor).put(update_sensor).delete(delete_sensor))
|
||
.route("/sensors/{id}/data", get(get_sensor_data))
|
||
.route("/sensors/{id}/display-config", get(get_display_config).put(save_display_config))
|
||
}
|
||
|
||
async fn list_sensors(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Vec<Sensor>>, StatusCode> {
|
||
let sensors = sqlx::query_as::<_, Sensor>("SELECT * FROM sensors ORDER BY name")
|
||
.fetch_all(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(Json(sensors))
|
||
}
|
||
|
||
async fn create_sensor(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<CreateSensor>,
|
||
) -> Result<(StatusCode, Json<Sensor>), (StatusCode, Json<serde_json::Value>)> {
|
||
let id = Uuid::new_v4().to_string();
|
||
let now = Utc::now().to_rfc3339();
|
||
let err = |msg: &str| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg })));
|
||
|
||
sqlx::query(
|
||
"INSERT INTO sensors (id, name, description, location, sensor_type, active, topic_id, display_config, created_at) \
|
||
VALUES (?, ?, ?, ?, ?, 1, ?, '{}', ?)"
|
||
)
|
||
.bind(&id).bind(&req.name).bind(&req.description)
|
||
.bind(&req.location).bind(&req.sensor_type)
|
||
.bind(&req.topic_id).bind(&now)
|
||
.execute(&state.pool).await.map_err(|e| err(&e.to_string()))?;
|
||
|
||
let sensor = sqlx::query_as::<_, Sensor>("SELECT * FROM sensors WHERE id = ?")
|
||
.bind(&id).fetch_one(&state.pool).await.map_err(|e| err(&e.to_string()))?;
|
||
|
||
Ok((StatusCode::CREATED, Json(sensor)))
|
||
}
|
||
|
||
async fn get_sensor(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<Sensor>, StatusCode> {
|
||
sqlx::query_as::<_, Sensor>("SELECT * FROM sensors WHERE id = ?")
|
||
.bind(&id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.map(Json)
|
||
.ok_or(StatusCode::NOT_FOUND)
|
||
}
|
||
|
||
async fn update_sensor(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Json(req): Json<UpdateSensor>,
|
||
) -> Result<Json<Sensor>, StatusCode> {
|
||
// Build dynamic update
|
||
let existing = sqlx::query_as::<_, Sensor>("SELECT * FROM sensors WHERE id = ?")
|
||
.bind(&id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.ok_or(StatusCode::NOT_FOUND)?;
|
||
|
||
let name = req.name.unwrap_or(existing.name);
|
||
let description = req.description.unwrap_or(existing.description);
|
||
let location = req.location.unwrap_or(existing.location);
|
||
let sensor_type = req.sensor_type.unwrap_or(existing.sensor_type);
|
||
let active = req.active.map(|b| if b { 1 } else { 0 }).unwrap_or(existing.active);
|
||
let topic_id = req.topic_id.or(existing.topic_id);
|
||
|
||
sqlx::query(
|
||
"UPDATE sensors SET name=?, description=?, location=?, sensor_type=?, active=?, topic_id=? WHERE id=?"
|
||
)
|
||
.bind(&name).bind(&description).bind(&location)
|
||
.bind(&sensor_type).bind(active).bind(&topic_id).bind(&id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
let sensor = sqlx::query_as::<_, Sensor>("SELECT * FROM sensors WHERE id = ?")
|
||
.bind(&id).fetch_one(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(Json(sensor))
|
||
}
|
||
|
||
async fn delete_sensor(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
// Transaction: reset topic + delete sensor
|
||
let mut tx = state.pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
// Get topic_id before deleting
|
||
let topic_id: Option<(Option<String>,)> = sqlx::query_as(
|
||
"SELECT topic_id FROM sensors WHERE id = ?"
|
||
).bind(&id).fetch_optional(&mut *tx).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
if topic_id.is_none() {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
// Reset topic to unapproved
|
||
if let Some((Some(tid),)) = &topic_id {
|
||
sqlx::query("UPDATE topics SET approved = 0 WHERE id = ?")
|
||
.bind(tid).execute(&mut *tx).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
|
||
sqlx::query("DELETE FROM sensors WHERE id = ?")
|
||
.bind(&id).execute(&mut *tx).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(StatusCode::NO_CONTENT)
|
||
}
|
||
|
||
async fn get_sensor_data(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Query(params): Query<DataQuery>,
|
||
) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||
let err = |msg: &str| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg })));
|
||
|
||
// Get the sensor's topic (dev_eui)
|
||
let sensor: Option<(Option<String>,)> = sqlx::query_as(
|
||
"SELECT topic_id FROM sensors WHERE id = ?"
|
||
).bind(&id).fetch_optional(&state.pool).await.map_err(|e| err(&e.to_string()))?;
|
||
|
||
let topic_id = sensor.ok_or_else(|| (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"Not found"}))))?
|
||
.0.ok_or_else(|| err("No topic assigned"))?;
|
||
|
||
let dev_eui: Option<(String,)> = sqlx::query_as(
|
||
"SELECT topic FROM topics WHERE id = ?"
|
||
).bind(&topic_id).fetch_optional(&state.pool).await.map_err(|e| err(&e.to_string()))?;
|
||
|
||
let dev_eui = dev_eui.ok_or_else(|| err("Topic not found"))?.0;
|
||
|
||
let hours = params.hours.unwrap_or(24.0);
|
||
let flux = format!(
|
||
r#"from(bucket: "{}")
|
||
|> range(start: -{}h)
|
||
|> filter(fn: (r) => r["dev_eui"] == "{}")
|
||
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")"#,
|
||
state.influx_bucket, hours as i64, dev_eui
|
||
);
|
||
|
||
// Query InfluxDB
|
||
let client = reqwest::Client::new();
|
||
let resp = client
|
||
.post(format!("{}/api/v2/query?org={}", state.influx_url, state.influx_org))
|
||
.header("Authorization", format!("Token {}", state.influx_token))
|
||
.header("Content-Type", "application/vnd.flux")
|
||
.header("Accept", "application/csv")
|
||
.body(flux)
|
||
.send().await.map_err(|e| err(&e.to_string()))?;
|
||
|
||
let csv_text = resp.text().await.map_err(|e| err(&e.to_string()))?;
|
||
let data = parse_influx_csv(&csv_text);
|
||
|
||
Ok(Json(data))
|
||
}
|
||
|
||
fn parse_influx_csv(csv: &str) -> Vec<serde_json::Value> {
|
||
let mut results = Vec::new();
|
||
let lines: Vec<&str> = csv.lines().collect();
|
||
|
||
if lines.len() < 2 {
|
||
return results;
|
||
}
|
||
|
||
// Find header line (first non-annotation line)
|
||
let mut header_idx = 0;
|
||
for (i, line) in lines.iter().enumerate() {
|
||
if !line.starts_with('#') && !line.is_empty() {
|
||
header_idx = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
let headers: Vec<&str> = lines[header_idx].split(',').collect();
|
||
|
||
for line in &lines[header_idx + 1..] {
|
||
if line.is_empty() || line.starts_with('#') {
|
||
continue;
|
||
}
|
||
let values: Vec<&str> = line.split(',').collect();
|
||
let mut obj = serde_json::Map::new();
|
||
|
||
for (i, header) in headers.iter().enumerate() {
|
||
if let Some(val) = values.get(i) {
|
||
let h = header.trim();
|
||
if h.is_empty() || h == "result" || h == "table" || h.starts_with('_') && h != "_time" {
|
||
continue;
|
||
}
|
||
// Try parse as number
|
||
if let Ok(f) = val.parse::<f64>() {
|
||
obj.insert(h.to_string(), serde_json::json!(f));
|
||
} else {
|
||
obj.insert(h.to_string(), serde_json::json!(val));
|
||
}
|
||
}
|
||
}
|
||
|
||
if !obj.is_empty() {
|
||
results.push(serde_json::Value::Object(obj));
|
||
}
|
||
}
|
||
|
||
results
|
||
}
|
||
|
||
async fn get_display_config(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||
let row: Option<(String,)> = sqlx::query_as(
|
||
"SELECT display_config FROM sensors WHERE id = ?"
|
||
).bind(&id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
let config_str = row.ok_or(StatusCode::NOT_FOUND)?.0;
|
||
let config: serde_json::Value = serde_json::from_str(&config_str)
|
||
.unwrap_or(serde_json::json!({}));
|
||
Ok(Json(config))
|
||
}
|
||
|
||
async fn save_display_config(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Json(config): Json<serde_json::Value>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
let config_str = serde_json::to_string(&config)
|
||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||
|
||
let result = sqlx::query("UPDATE sensors SET display_config = ? WHERE id = ?")
|
||
.bind(&config_str).bind(&id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
if result.rows_affected() == 0 {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
Ok(StatusCode::OK)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-api`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/api/src/sensors.rs
|
||
git commit -m "feat(api): implement sensor CRUD, InfluxDB proxy, and display config endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Topics & Alarms Endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/api/src/topics.rs`
|
||
- Modify: `backend/api/src/alarms.rs`
|
||
|
||
- [ ] **Step 1: Implement topics.rs**
|
||
|
||
```rust
|
||
// backend/api/src/topics.rs
|
||
use axum::{
|
||
extract::{Path, State},
|
||
http::StatusCode,
|
||
routing::{get, put},
|
||
Json, Router,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use crate::AppState;
|
||
|
||
#[derive(Serialize, sqlx::FromRow)]
|
||
pub struct Topic {
|
||
id: String,
|
||
topic: String,
|
||
approved: i32,
|
||
last_seen: Option<String>,
|
||
alarm_status: String,
|
||
alarm_threshold_hours: f64,
|
||
created_at: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateTopic {
|
||
approved: Option<bool>,
|
||
sensor_id: Option<String>, // assign to sensor
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ThresholdUpdate {
|
||
threshold_minutes: f64,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/topics", get(list_topics))
|
||
.route("/topics/{id}", put(update_topic))
|
||
.route("/topics/{id}/threshold", put(set_threshold))
|
||
}
|
||
|
||
async fn list_topics(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Vec<Topic>>, StatusCode> {
|
||
let topics = sqlx::query_as::<_, Topic>("SELECT * FROM topics ORDER BY created_at DESC")
|
||
.fetch_all(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(Json(topics))
|
||
}
|
||
|
||
async fn update_topic(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Json(req): Json<UpdateTopic>,
|
||
) -> Result<Json<Topic>, StatusCode> {
|
||
if let Some(approved) = req.approved {
|
||
sqlx::query("UPDATE topics SET approved = ? WHERE id = ?")
|
||
.bind(if approved { 1 } else { 0 }).bind(&id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
|
||
if let Some(sensor_id) = &req.sensor_id {
|
||
// Assign this topic to the sensor
|
||
sqlx::query("UPDATE sensors SET topic_id = ? WHERE id = ?")
|
||
.bind(&id).bind(sensor_id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
// Auto-approve when assigned
|
||
sqlx::query("UPDATE topics SET approved = 1 WHERE id = ?")
|
||
.bind(&id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
|
||
sqlx::query_as::<_, Topic>("SELECT * FROM topics WHERE id = ?")
|
||
.bind(&id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.map(Json)
|
||
.ok_or(StatusCode::NOT_FOUND)
|
||
}
|
||
|
||
async fn set_threshold(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<String>,
|
||
Json(req): Json<ThresholdUpdate>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
let hours = req.threshold_minutes / 60.0;
|
||
let result = sqlx::query("UPDATE topics SET alarm_threshold_hours = ? WHERE id = ?")
|
||
.bind(hours).bind(&id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
if result.rows_affected() == 0 {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
Ok(StatusCode::OK)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement alarms.rs**
|
||
|
||
```rust
|
||
// backend/api/src/alarms.rs
|
||
use axum::{
|
||
extract::State,
|
||
http::StatusCode,
|
||
routing::get,
|
||
Json, Router,
|
||
};
|
||
use serde::Serialize;
|
||
use crate::AppState;
|
||
|
||
#[derive(Serialize)]
|
||
pub struct AlarmSummary {
|
||
active: i64,
|
||
warning: i64,
|
||
alarm: i64,
|
||
unknown: i64,
|
||
alarms: Vec<AlarmEntry>,
|
||
}
|
||
|
||
#[derive(Serialize, sqlx::FromRow)]
|
||
pub struct AlarmEntry {
|
||
topic_id: String,
|
||
dev_eui: String,
|
||
alarm_status: String,
|
||
last_seen: Option<String>,
|
||
sensor_name: Option<String>,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/alarms", get(get_alarms))
|
||
}
|
||
|
||
async fn get_alarms(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<AlarmSummary>, StatusCode> {
|
||
let counts: Vec<(String, i64)> = sqlx::query_as(
|
||
"SELECT alarm_status, COUNT(*) FROM topics WHERE approved = 1 GROUP BY alarm_status"
|
||
).fetch_all(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
let mut summary = AlarmSummary {
|
||
active: 0, warning: 0, alarm: 0, unknown: 0, alarms: Vec::new(),
|
||
};
|
||
|
||
for (status, count) in &counts {
|
||
match status.as_str() {
|
||
"active" => summary.active = *count,
|
||
"warning" => summary.warning = *count,
|
||
"alarm" => summary.alarm = *count,
|
||
"unknown" => summary.unknown = *count,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
summary.alarms = sqlx::query_as::<_, AlarmEntry>(
|
||
"SELECT t.id as topic_id, t.topic as dev_eui, t.alarm_status, t.last_seen, s.name as sensor_name \
|
||
FROM topics t LEFT JOIN sensors s ON s.topic_id = t.id \
|
||
WHERE t.approved = 1 AND t.alarm_status IN ('warning', 'alarm') \
|
||
ORDER BY t.alarm_status DESC, t.last_seen ASC"
|
||
).fetch_all(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
Ok(Json(summary))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-api`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/api/src/topics.rs backend/api/src/alarms.rs
|
||
git commit -m "feat(api): implement topics management and alarm summary endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Users & 2FA Endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/api/src/users.rs`
|
||
|
||
- [ ] **Step 1: Implement users.rs**
|
||
|
||
```rust
|
||
// backend/api/src/users.rs
|
||
use axum::{
|
||
extract::{Path, State},
|
||
http::StatusCode,
|
||
routing::{delete, get, post},
|
||
Json, Router,
|
||
};
|
||
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
|
||
use rand::rngs::OsRng;
|
||
use serde::{Deserialize, Serialize};
|
||
use chrono::Utc;
|
||
use crate::AppState;
|
||
|
||
#[derive(Serialize, sqlx::FromRow)]
|
||
pub struct UserResponse {
|
||
id: i64,
|
||
username: String,
|
||
role: String,
|
||
totp_enabled: i32,
|
||
created_at: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateUser {
|
||
username: String,
|
||
password: String,
|
||
role: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateUser {
|
||
username: Option<String>,
|
||
password: Option<String>,
|
||
role: Option<String>,
|
||
}
|
||
|
||
pub fn routes() -> Router<AppState> {
|
||
Router::new()
|
||
.route("/users", get(list_users).post(create_user))
|
||
.route("/users/{id}", put(update_user).delete(delete_user))
|
||
.route("/users/{id}/totp", post(enable_totp).delete(disable_totp))
|
||
}
|
||
|
||
use axum::routing::put;
|
||
|
||
async fn list_users(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
|
||
let users = sqlx::query_as::<_, UserResponse>(
|
||
"SELECT id, username, role, totp_enabled, created_at FROM users ORDER BY username"
|
||
).fetch_all(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(Json(users))
|
||
}
|
||
|
||
async fn create_user(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<CreateUser>,
|
||
) -> Result<(StatusCode, Json<UserResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||
let err = |msg: &str| (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg })));
|
||
|
||
let salt = SaltString::generate(&mut OsRng);
|
||
let hash = Argon2::default()
|
||
.hash_password(req.password.as_bytes(), &salt)
|
||
.map_err(|_| err("Hash failed"))?
|
||
.to_string();
|
||
let role = req.role.unwrap_or_else(|| "user".into());
|
||
let now = Utc::now().to_rfc3339();
|
||
|
||
sqlx::query(
|
||
"INSERT INTO users (username, password_hash, role, totp_enabled, created_at) VALUES (?, ?, ?, 0, ?)"
|
||
)
|
||
.bind(&req.username).bind(&hash).bind(&role).bind(&now)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| err("Username already exists"))?;
|
||
|
||
let user = sqlx::query_as::<_, UserResponse>(
|
||
"SELECT id, username, role, totp_enabled, created_at FROM users WHERE username = ?"
|
||
).bind(&req.username).fetch_one(&state.pool).await
|
||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"DB error"}))))?;
|
||
|
||
Ok((StatusCode::CREATED, Json(user)))
|
||
}
|
||
|
||
async fn update_user(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(req): Json<UpdateUser>,
|
||
) -> Result<Json<UserResponse>, StatusCode> {
|
||
if let Some(username) = &req.username {
|
||
sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||
.bind(username).bind(id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
if let Some(password) = &req.password {
|
||
let salt = SaltString::generate(&mut OsRng);
|
||
let hash = Argon2::default()
|
||
.hash_password(password.as_bytes(), &salt)
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.to_string();
|
||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||
.bind(&hash).bind(id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
if let Some(role) = &req.role {
|
||
sqlx::query("UPDATE users SET role = ? WHERE id = ?")
|
||
.bind(role).bind(id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
}
|
||
|
||
sqlx::query_as::<_, UserResponse>(
|
||
"SELECT id, username, role, totp_enabled, created_at FROM users WHERE id = ?"
|
||
).bind(id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.map(Json)
|
||
.ok_or(StatusCode::NOT_FOUND)
|
||
}
|
||
|
||
async fn delete_user(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
let result = sqlx::query("DELETE FROM users WHERE id = ?")
|
||
.bind(id).execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
if result.rows_affected() == 0 {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
Ok(StatusCode::NO_CONTENT)
|
||
}
|
||
|
||
async fn enable_totp(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||
let secret = totp_rs::Secret::generate_secret();
|
||
let secret_base32 = secret.to_encoded().to_string();
|
||
|
||
let username: Option<(String,)> = sqlx::query_as("SELECT username FROM users WHERE id = ?")
|
||
.bind(id).fetch_optional(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
let username = username.ok_or(StatusCode::NOT_FOUND)?.0;
|
||
|
||
sqlx::query("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?")
|
||
.bind(&secret_base32).bind(id)
|
||
.execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
let secret_bytes = totp_rs::Secret::Encoded(secret_base32.clone())
|
||
.to_bytes()
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
let totp = totp_rs::TOTP::new(
|
||
totp_rs::Algorithm::SHA1, 6, 1, 30, secret_bytes,
|
||
).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
||
let uri = totp.get_url(&username, "LoRaWEB");
|
||
|
||
Ok(Json(serde_json::json!({
|
||
"secret": secret_base32,
|
||
"uri": uri,
|
||
})))
|
||
}
|
||
|
||
async fn disable_totp(
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
sqlx::query("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?")
|
||
.bind(id).execute(&state.pool).await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
Ok(StatusCode::OK)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire auth middleware in main.rs**
|
||
|
||
Update `api_routes()` in main.rs:
|
||
|
||
```rust
|
||
fn api_routes(state: AppState) -> Router {
|
||
let public = Router::new()
|
||
.merge(auth::routes());
|
||
|
||
let protected = Router::new()
|
||
.merge(sensors::routes())
|
||
.merge(topics::routes())
|
||
.merge(alarms::routes())
|
||
.route_layer(axum::middleware::from_fn_with_state(state.clone(), auth::require_auth));
|
||
|
||
let admin = Router::new()
|
||
.merge(users::routes())
|
||
.route_layer(axum::middleware::from_fn(auth::require_admin))
|
||
.route_layer(axum::middleware::from_fn_with_state(state.clone(), auth::require_auth));
|
||
|
||
Router::new()
|
||
.merge(public)
|
||
.merge(protected)
|
||
.merge(admin)
|
||
.with_state(state)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify compilation**
|
||
|
||
Run: `cargo build -p loraweb-api`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/api/
|
||
git commit -m "feat(api): add user CRUD with 2FA, wire auth middleware for protected/admin routes"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4: Frontend
|
||
|
||
### Task 11: Frontend Scaffolding
|
||
|
||
**Files:**
|
||
- Create: `frontend/package.json`
|
||
- Create: `frontend/tsconfig.json`
|
||
- Create: `frontend/vite.config.ts`
|
||
- Create: `frontend/index.html`
|
||
- Create: `frontend/src/main.tsx`
|
||
- Create: `frontend/src/App.tsx`
|
||
- Create: `frontend/src/index.css`
|
||
|
||
- [ ] **Step 1: Initialize frontend project**
|
||
|
||
Run from project root:
|
||
```bash
|
||
cd frontend
|
||
npm init -y
|
||
npm install react@18 react-dom@18 react-router-dom@6 axios recharts lucide-react react-hot-toast
|
||
npm install -D typescript @types/react @types/react-dom vite @vitejs/plugin-react tailwindcss @tailwindcss/vite
|
||
```
|
||
|
||
Then update `package.json` scripts:
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc --noEmit && vite build",
|
||
"preview": "vite preview"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create vite.config.ts**
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
import tailwindcss from '@tailwindcss/vite'
|
||
|
||
export default defineConfig({
|
||
plugins: [react(), tailwindcss()],
|
||
server: {
|
||
port: 3000,
|
||
proxy: {
|
||
'/api': 'http://localhost:3001',
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Create tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"isolatedModules": true,
|
||
"moduleDetection": "force",
|
||
"noEmit": true,
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"forceConsistentCasingInFileNames": true
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create index.html**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>LoRaWEB</title>
|
||
</head>
|
||
<body class="bg-slate-900 text-slate-100">
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 5: Create src/index.css**
|
||
|
||
```css
|
||
@import "tailwindcss";
|
||
|
||
@keyframes slide-in {
|
||
from { transform: translateX(-100%); }
|
||
to { transform: translateX(0); }
|
||
}
|
||
|
||
.animate-slide-in {
|
||
animation: slide-in 0.2s ease-out;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create src/main.tsx**
|
||
|
||
```tsx
|
||
import React from 'react'
|
||
import ReactDOM from 'react-dom/client'
|
||
import App from './App'
|
||
import './index.css'
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 7: Create src/App.tsx (skeleton)**
|
||
|
||
```tsx
|
||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { Toaster } from 'react-hot-toast'
|
||
|
||
function App() {
|
||
return (
|
||
<BrowserRouter>
|
||
<Toaster position="top-right" />
|
||
<Routes>
|
||
<Route path="/" element={<div>LoRaWEB - Coming Soon</div>} />
|
||
</Routes>
|
||
</BrowserRouter>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
```
|
||
|
||
- [ ] **Step 8: Verify dev server starts**
|
||
|
||
Run: `cd frontend && npm run dev`
|
||
Expected: Vite dev server starts on port 3000.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/
|
||
git commit -m "feat(frontend): scaffold React 18 + Vite 6 + Tailwind 4 project"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: API Client & Auth Store
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/client.ts`
|
||
- Create: `frontend/src/store/authStore.tsx`
|
||
|
||
- [ ] **Step 1: Create API client**
|
||
|
||
```typescript
|
||
// frontend/src/api/client.ts
|
||
import axios from 'axios'
|
||
|
||
const api = axios.create({ baseURL: '/api' })
|
||
|
||
api.interceptors.request.use((config) => {
|
||
const token = localStorage.getItem('loraweb_token')
|
||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||
return config
|
||
})
|
||
|
||
api.interceptors.response.use(
|
||
(res) => res,
|
||
(err) => {
|
||
if (err.response?.status === 401) {
|
||
localStorage.removeItem('loraweb_token')
|
||
localStorage.removeItem('loraweb_user')
|
||
window.location.href = '/login'
|
||
}
|
||
return Promise.reject(err)
|
||
}
|
||
)
|
||
|
||
// Auth
|
||
export const login = (username: string, password: string) =>
|
||
api.post('/auth/login', { username, password })
|
||
|
||
export const loginTotp = (totp_token: string, totp_code: string) =>
|
||
api.post('/auth/login', { totp_token, totp_code })
|
||
|
||
// Sensors
|
||
export const getSensors = () => api.get('/sensors')
|
||
export const createSensor = (data: any) => api.post('/sensors', data)
|
||
export const updateSensor = (id: string, data: any) => api.put(`/sensors/${id}`, data)
|
||
export const deleteSensor = (id: string) => api.delete(`/sensors/${id}`)
|
||
export const getSensorData = (id: string, hours: number) =>
|
||
api.get(`/sensors/${id}/data?hours=${hours}`)
|
||
export const getDisplayConfig = (id: string) => api.get(`/sensors/${id}/display-config`)
|
||
export const saveDisplayConfig = (id: string, config: any) =>
|
||
api.put(`/sensors/${id}/display-config`, config)
|
||
|
||
// Topics
|
||
export const getTopics = () => api.get('/topics')
|
||
export const updateTopic = (id: string, data: any) => api.put(`/topics/${id}`, data)
|
||
export const setTopicThreshold = (id: string, minutes: number) =>
|
||
api.put(`/topics/${id}/threshold`, { threshold_minutes: minutes })
|
||
|
||
// Alarms
|
||
export const getAlarms = () => api.get('/alarms')
|
||
|
||
// Users
|
||
export const getUsers = () => api.get('/users')
|
||
export const createUser = (data: any) => api.post('/users', data)
|
||
export const updateUser = (id: number, data: any) => api.put(`/users/${id}`, data)
|
||
export const deleteUser = (id: number) => api.delete(`/users/${id}`)
|
||
export const enableTotp = (id: number) => api.post(`/users/${id}/totp`)
|
||
export const disableTotp = (id: number) => api.delete(`/users/${id}/totp`)
|
||
|
||
export default api
|
||
```
|
||
|
||
- [ ] **Step 2: Create auth store**
|
||
|
||
```tsx
|
||
// frontend/src/store/authStore.tsx
|
||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||
|
||
interface User {
|
||
id: number
|
||
username: string
|
||
role: string
|
||
}
|
||
|
||
interface AuthContextType {
|
||
user: User | null
|
||
token: string | null
|
||
login: (token: string, user: User) => void
|
||
logout: () => void
|
||
isAdmin: boolean
|
||
}
|
||
|
||
const AuthContext = createContext<AuthContextType | null>(null)
|
||
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const [token, setToken] = useState<string | null>(
|
||
() => localStorage.getItem('loraweb_token')
|
||
)
|
||
const [user, setUser] = useState<User | null>(() => {
|
||
const stored = localStorage.getItem('loraweb_user')
|
||
return stored ? JSON.parse(stored) : null
|
||
})
|
||
|
||
const loginFn = (token: string, user: User) => {
|
||
localStorage.setItem('loraweb_token', token)
|
||
localStorage.setItem('loraweb_user', JSON.stringify(user))
|
||
setToken(token)
|
||
setUser(user)
|
||
}
|
||
|
||
const logout = () => {
|
||
localStorage.removeItem('loraweb_token')
|
||
localStorage.removeItem('loraweb_user')
|
||
setToken(null)
|
||
setUser(null)
|
||
}
|
||
|
||
return (
|
||
<AuthContext.Provider value={{
|
||
user, token, login: loginFn, logout,
|
||
isAdmin: user?.role === 'admin',
|
||
}}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
)
|
||
}
|
||
|
||
export function useAuth() {
|
||
const ctx = useContext(AuthContext)
|
||
if (!ctx) throw new Error('useAuth must be inside AuthProvider')
|
||
return ctx
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/ frontend/src/store/
|
||
git commit -m "feat(frontend): add Axios API client and React Context auth store"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Layout Component (Responsive Sidebar)
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/Layout.tsx`
|
||
- Create: `frontend/src/components/StatusBadge.tsx`
|
||
|
||
- [ ] **Step 1: Create Layout.tsx**
|
||
|
||
```tsx
|
||
// frontend/src/components/Layout.tsx
|
||
import { useState } from 'react'
|
||
import { Link, useLocation } from 'react-router-dom'
|
||
import { useAuth } from '../store/authStore'
|
||
import {
|
||
LayoutDashboard, Radio, AlertTriangle, Users, HelpCircle,
|
||
Menu, X, LogOut
|
||
} from 'lucide-react'
|
||
|
||
const navItems = [
|
||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||
{ to: '/sensors', icon: Radio, label: 'Sensoren' },
|
||
{ to: '/unknown', icon: HelpCircle, label: 'Unbekannte Geräte' },
|
||
{ to: '/alarms', icon: AlertTriangle, label: 'Alarme' },
|
||
]
|
||
|
||
const adminItems = [
|
||
{ to: '/users', icon: Users, label: 'Benutzer' },
|
||
]
|
||
|
||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const { user, logout, isAdmin } = useAuth()
|
||
const location = useLocation()
|
||
|
||
const items = isAdmin ? [...navItems, ...adminItems] : navItems
|
||
|
||
const NavLinks = () => (
|
||
<nav className="flex flex-col gap-1">
|
||
{items.map((item) => {
|
||
const active = location.pathname === item.to
|
||
return (
|
||
<Link
|
||
key={item.to}
|
||
to={item.to}
|
||
onClick={() => setDrawerOpen(false)}
|
||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||
active
|
||
? 'bg-slate-700 text-white'
|
||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||
}`}
|
||
>
|
||
<item.icon size={20} />
|
||
<span>{item.label}</span>
|
||
</Link>
|
||
)
|
||
})}
|
||
</nav>
|
||
)
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-900 text-slate-100">
|
||
{/* Mobile top bar */}
|
||
<div className="lg:hidden flex items-center justify-between px-4 py-3 bg-slate-800 border-b border-slate-700">
|
||
<button onClick={() => setDrawerOpen(true)}>
|
||
<Menu size={24} />
|
||
</button>
|
||
<span className="font-bold text-lg">LoRaWEB</span>
|
||
<div className="w-6" />
|
||
</div>
|
||
|
||
{/* Mobile drawer */}
|
||
{drawerOpen && (
|
||
<div className="lg:hidden fixed inset-0 z-50 flex">
|
||
<div className="fixed inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
|
||
<div className="relative w-64 bg-slate-800 p-4 flex flex-col gap-6 animate-slide-in">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-bold text-lg">LoRaWEB</span>
|
||
<button onClick={() => setDrawerOpen(false)}><X size={20} /></button>
|
||
</div>
|
||
<NavLinks />
|
||
<div className="mt-auto">
|
||
<button onClick={logout} className="flex items-center gap-2 text-slate-400 hover:text-white">
|
||
<LogOut size={18} /> Abmelden
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex">
|
||
{/* Desktop sidebar */}
|
||
<aside className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0 bg-slate-800 border-r border-slate-700 p-4">
|
||
<div className="font-bold text-xl mb-8">LoRaWEB</div>
|
||
<NavLinks />
|
||
<div className="mt-auto flex items-center justify-between text-sm text-slate-400">
|
||
<span>{user?.username}</span>
|
||
<button onClick={logout} className="hover:text-white">
|
||
<LogOut size={18} />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main content */}
|
||
<main className="flex-1 lg:ml-64 p-4 lg:p-8">
|
||
{children}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create StatusBadge.tsx**
|
||
|
||
```tsx
|
||
// frontend/src/components/StatusBadge.tsx
|
||
interface Props {
|
||
status: string
|
||
}
|
||
|
||
const colors: Record<string, string> = {
|
||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||
alarm: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||
unknown: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||
}
|
||
|
||
export default function StatusBadge({ status }: Props) {
|
||
const cls = colors[status] || colors.unknown
|
||
return (
|
||
<span className={`px-2 py-0.5 rounded-full border text-xs font-medium ${cls}`}>
|
||
{status}
|
||
</span>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/
|
||
git commit -m "feat(frontend): add responsive Layout with sidebar/drawer and StatusBadge"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Login Page
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/pages/LoginPage.tsx`
|
||
- Modify: `frontend/src/App.tsx`
|
||
|
||
- [ ] **Step 1: Create LoginPage.tsx**
|
||
|
||
```tsx
|
||
// frontend/src/pages/LoginPage.tsx
|
||
import { useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useAuth } from '../store/authStore'
|
||
import { login as apiLogin, loginTotp } from '../api/client'
|
||
import toast from 'react-hot-toast'
|
||
import { Radio } from 'lucide-react'
|
||
|
||
export default function LoginPage() {
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [totpCode, setTotpCode] = useState('')
|
||
const [totpToken, setTotpToken] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const { login } = useAuth()
|
||
const navigate = useNavigate()
|
||
|
||
const handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
try {
|
||
if (totpToken) {
|
||
const { data } = await loginTotp(totpToken, totpCode)
|
||
const payload = JSON.parse(atob(data.token.split('.')[1]))
|
||
login(data.token, { id: payload.sub, username, role: payload.role })
|
||
navigate('/')
|
||
} else {
|
||
const { data } = await apiLogin(username, password)
|
||
if (data.totp_required) {
|
||
setTotpToken(data.totp_token)
|
||
toast('Bitte TOTP-Code eingeben')
|
||
} else {
|
||
const payload = JSON.parse(atob(data.token.split('.')[1]))
|
||
login(data.token, { id: payload.sub, username, role: payload.role })
|
||
navigate('/')
|
||
}
|
||
}
|
||
} catch {
|
||
toast.error('Anmeldung fehlgeschlagen')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
|
||
<form onSubmit={handleLogin} className="w-full max-w-sm bg-slate-800 rounded-xl p-6 space-y-4">
|
||
<div className="flex items-center gap-2 justify-center mb-4">
|
||
<Radio className="text-blue-400" size={28} />
|
||
<h1 className="text-2xl font-bold">LoRaWEB</h1>
|
||
</div>
|
||
|
||
{!totpToken ? (
|
||
<>
|
||
<input
|
||
type="text"
|
||
placeholder="Benutzername"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
className="w-full px-3 py-2 bg-slate-700 rounded-lg border border-slate-600 focus:border-blue-500 outline-none"
|
||
autoFocus
|
||
/>
|
||
<input
|
||
type="password"
|
||
placeholder="Passwort"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full px-3 py-2 bg-slate-700 rounded-lg border border-slate-600 focus:border-blue-500 outline-none"
|
||
/>
|
||
</>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
placeholder="TOTP-Code"
|
||
value={totpCode}
|
||
onChange={(e) => setTotpCode(e.target.value)}
|
||
className="w-full px-3 py-2 bg-slate-700 rounded-lg border border-slate-600 focus:border-blue-500 outline-none text-center text-2xl tracking-widest"
|
||
maxLength={6}
|
||
autoFocus
|
||
/>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium disabled:opacity-50"
|
||
>
|
||
{loading ? 'Anmelden...' : 'Anmelden'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update App.tsx with routing and auth**
|
||
|
||
```tsx
|
||
// frontend/src/App.tsx
|
||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { Toaster } from 'react-hot-toast'
|
||
import { AuthProvider, useAuth } from './store/authStore'
|
||
import Layout from './components/Layout'
|
||
import LoginPage from './pages/LoginPage'
|
||
import DashboardPage from './pages/DashboardPage'
|
||
import SensorsPage from './pages/SensorsPage'
|
||
import UnknownTopicsPage from './pages/UnknownTopicsPage'
|
||
import AlarmsPage from './pages/AlarmsPage'
|
||
import UsersPage from './pages/UsersPage'
|
||
|
||
function ProtectedRoute({ children, admin }: { children: React.ReactNode; admin?: boolean }) {
|
||
const { token, isAdmin } = useAuth()
|
||
if (!token) return <Navigate to="/login" />
|
||
if (admin && !isAdmin) return <Navigate to="/" />
|
||
return <Layout>{children}</Layout>
|
||
}
|
||
|
||
function App() {
|
||
return (
|
||
<AuthProvider>
|
||
<BrowserRouter>
|
||
<Toaster position="top-right" toastOptions={{
|
||
style: { background: '#1e293b', color: '#f1f5f9', border: '1px solid #334155' }
|
||
}} />
|
||
<Routes>
|
||
<Route path="/login" element={<LoginPage />} />
|
||
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||
<Route path="/sensors" element={<ProtectedRoute><SensorsPage /></ProtectedRoute>} />
|
||
<Route path="/unknown" element={<ProtectedRoute><UnknownTopicsPage /></ProtectedRoute>} />
|
||
<Route path="/alarms" element={<ProtectedRoute><AlarmsPage /></ProtectedRoute>} />
|
||
<Route path="/users" element={<ProtectedRoute admin><UsersPage /></ProtectedRoute>} />
|
||
</Routes>
|
||
</BrowserRouter>
|
||
</AuthProvider>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
```
|
||
|
||
- [ ] **Step 3: Create placeholder page files**
|
||
|
||
Create stub files for DashboardPage, SensorsPage, UnknownTopicsPage, AlarmsPage, UsersPage — each exporting a simple `<div>Page Name</div>` component.
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
Run: `cd frontend && npx tsc --noEmit && npm run build`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/
|
||
git commit -m "feat(frontend): add login page, routing, auth protection, page stubs"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Dashboard Page
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/DashboardPage.tsx`
|
||
|
||
- [ ] **Step 1: Implement DashboardPage**
|
||
|
||
```tsx
|
||
// frontend/src/pages/DashboardPage.tsx
|
||
import { useEffect, useState } from 'react'
|
||
import { getAlarms } from '../api/client'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
import { Activity, AlertTriangle, AlertOctagon, HelpCircle } from 'lucide-react'
|
||
|
||
interface AlarmData {
|
||
active: number; warning: number; alarm: number; unknown: number
|
||
alarms: Array<{ topic_id: string; dev_eui: string; alarm_status: string; last_seen: string | null; sensor_name: string | null }>
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const [data, setData] = useState<AlarmData | null>(null)
|
||
|
||
const load = () => getAlarms().then(r => setData(r.data)).catch(() => {})
|
||
|
||
useEffect(() => {
|
||
load()
|
||
const timer = setInterval(load, 15000)
|
||
return () => clearInterval(timer)
|
||
}, [])
|
||
|
||
if (!data) return <div className="animate-pulse">Laden...</div>
|
||
|
||
const tiles = [
|
||
{ label: 'Aktiv', count: data.active, icon: Activity, color: 'text-green-400 bg-green-500/10' },
|
||
{ label: 'Warnung', count: data.warning, icon: AlertTriangle, color: 'text-yellow-400 bg-yellow-500/10' },
|
||
{ label: 'Alarm', count: data.alarm, icon: AlertOctagon, color: 'text-red-400 bg-red-500/10' },
|
||
{ label: 'Unbekannt', count: data.unknown, icon: HelpCircle, color: 'text-slate-400 bg-slate-500/10' },
|
||
]
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||
{tiles.map(t => (
|
||
<div key={t.label} className={`rounded-xl p-4 border border-slate-700 ${t.color}`}>
|
||
<t.icon size={24} className="mb-2" />
|
||
<div className="text-3xl font-bold">{t.count}</div>
|
||
<div className="text-sm opacity-80">{t.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{data.alarms.length > 0 && (
|
||
<div>
|
||
<h2 className="text-lg font-semibold mb-3">Aktive Alarme</h2>
|
||
<div className="space-y-2">
|
||
{data.alarms.map(a => (
|
||
<div key={a.topic_id} className="flex items-center justify-between bg-slate-800 rounded-lg p-3 border border-slate-700">
|
||
<div>
|
||
<div className="font-medium">{a.sensor_name || a.dev_eui}</div>
|
||
<div className="text-sm text-slate-400">{a.dev_eui}</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-slate-400">
|
||
{a.last_seen ? new Date(a.last_seen).toLocaleString('de-DE') : 'Nie gesehen'}
|
||
</span>
|
||
<StatusBadge status={a.alarm_status} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/DashboardPage.tsx
|
||
git commit -m "feat(frontend): implement Dashboard with status tiles and alarm list"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Sensors Page (CRUD + DataModal with Charts)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/SensorsPage.tsx`
|
||
|
||
This is the most complex frontend component. It includes:
|
||
- Sensor list (table on desktop, cards on mobile)
|
||
- Create/Edit/Delete modals
|
||
- DataModal with two tabs: "Diagramme" and "Einrichten"
|
||
- Multi-panel charts (Recharts)
|
||
- Auto-refresh
|
||
|
||
- [ ] **Step 1: Implement SensorsPage.tsx**
|
||
|
||
This file is large (~500 lines). Key structure:
|
||
|
||
```tsx
|
||
// frontend/src/pages/SensorsPage.tsx
|
||
// Imports, types, and helper functions
|
||
// SensorsPage component:
|
||
// - State: sensors list, selected sensor, modal state, data, display config
|
||
// - Load sensors on mount + 15s interval
|
||
// - CRUD functions (create, update, delete)
|
||
// - DataModal component (inline):
|
||
// - Tab 1 "Diagramme": renders charts per panel (D1-D8)
|
||
// - LineChart, BarChart, PieChart from Recharts
|
||
// - Time selector (1h/6h/24h/7d/30d/90d)
|
||
// - RF metadata toggle
|
||
// - Data table (last 20 values)
|
||
// - Tab 2 "Einrichten": per-field config
|
||
// - Label, Unit, Diagram type, Panel assignment
|
||
// - Save button → PUT /api/sensors/:id/display-config
|
||
// - Desktop: table with actions
|
||
// - Mobile: card list with actions
|
||
```
|
||
|
||
**Important TypeScript interfaces:**
|
||
|
||
```typescript
|
||
interface FieldConfig {
|
||
key: string // InfluxDB field name (e.g. "temperature_1")
|
||
label: string // Display name (e.g. "Temperatur 1")
|
||
unit: string // Unit (e.g. "°C")
|
||
type: 'line' | 'bar_day' | 'bar_week' | 'pie'
|
||
visible: boolean
|
||
diagram: number // Panel number 1-8 (fields with same number → same chart)
|
||
}
|
||
|
||
type DisplayConfig = Record<string, FieldConfig>
|
||
```
|
||
|
||
**Component structure:**
|
||
- `SensorsPage` — top-level, manages sensor list and CRUD
|
||
- `SensorTable` (desktop) / `SensorCards` (mobile) — list display
|
||
- `SensorFormModal` — create/edit form with topic assignment
|
||
- `DataModal` — two tabs, full-screen on mobile (bottom-sheet pattern: `fixed inset-0 flex items-end sm:items-center`)
|
||
- Tab "Diagramme": `ChartPanel` components, one per unique `diagram` number
|
||
- Tab "Einrichten": `FieldConfigRow` per field with inputs for label, unit, type, panel
|
||
|
||
**Chart panel grouping algorithm:**
|
||
```typescript
|
||
// Group fields by diagram number
|
||
const panels = Object.values(config)
|
||
.filter(f => f.visible)
|
||
.reduce((acc, f) => {
|
||
(acc[f.diagram] ??= []).push(f)
|
||
return acc
|
||
}, {} as Record<number, FieldConfig[]>)
|
||
// Render one <ResponsiveContainer> per panel entry
|
||
```
|
||
|
||
**Bar chart daily aggregation:**
|
||
```typescript
|
||
const byDay = data.reduce((acc, row) => {
|
||
const day = row._time?.substring(0, 10) // "2026-03-19"
|
||
if (!acc[day]) acc[day] = {}
|
||
for (const f of fields) {
|
||
acc[day][f.key] = (acc[day][f.key] || 0) + (row[f.key] || 0)
|
||
}
|
||
return acc
|
||
}, {} as Record<string, Record<string, number>>)
|
||
```
|
||
|
||
**All pages must include auto-refresh (15s):**
|
||
```typescript
|
||
useEffect(() => {
|
||
loadData()
|
||
const timer = setInterval(loadData, 15000)
|
||
return () => clearInterval(timer)
|
||
}, [])
|
||
```
|
||
|
||
Key patterns:
|
||
- `connectNulls={true}` on `<Line>` components
|
||
- Frontend aggregation for bar charts (group by date/week, sum values)
|
||
- Panel grouping: fields with same `diagram` number rendered in one `<ResponsiveContainer>`
|
||
- Bottom-sheet pattern for mobile: `items-end` + `rounded-t-2xl`
|
||
- Time ranges as buttons: `['1h','6h','24h','7d','30d','90d']` mapped to hours `[1,6,24,168,720,2160]`
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `cd frontend && npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/SensorsPage.tsx
|
||
git commit -m "feat(frontend): implement SensorsPage with CRUD, DataModal, multi-panel charts"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Remaining Pages (Unknown Topics, Alarms, Users)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/UnknownTopicsPage.tsx`
|
||
- Modify: `frontend/src/pages/AlarmsPage.tsx`
|
||
- Modify: `frontend/src/pages/UsersPage.tsx`
|
||
|
||
- [ ] **Step 1: Implement UnknownTopicsPage.tsx**
|
||
|
||
List of unapproved topics with dropdown to assign to free (unassigned) sensors. On assign: `PUT /api/topics/:id` with `{ sensor_id, approved: true }`.
|
||
|
||
- [ ] **Step 2: Implement AlarmsPage.tsx**
|
||
|
||
List of topics with `alarm_status` in `['warning', 'alarm']`. Similar to dashboard alarm list but full page with more detail.
|
||
|
||
- [ ] **Step 3: Implement UsersPage.tsx**
|
||
|
||
Admin-only CRUD for users. Create/edit modal. Enable/disable 2FA buttons. Show TOTP secret/URI when enabling.
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
Run: `cd frontend && npx tsc --noEmit && npm run build`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/
|
||
git commit -m "feat(frontend): implement UnknownTopics, Alarms, and Users pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5: Infrastructure
|
||
|
||
### Task 18: Dockerfiles
|
||
|
||
**Files:**
|
||
- Create: `backend/daemon/Dockerfile`
|
||
- Create: `backend/api/Dockerfile`
|
||
- Create: `frontend/Dockerfile`
|
||
- Create: `frontend/nginx.conf`
|
||
- Create: `.dockerignore`
|
||
|
||
- [ ] **Step 1: Create Rust Dockerfile (daemon)**
|
||
|
||
```dockerfile
|
||
# backend/daemon/Dockerfile
|
||
# Build context: project root
|
||
FROM rust:1.88-slim AS builder
|
||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||
WORKDIR /app
|
||
COPY Cargo.toml ./
|
||
COPY backend/daemon/Cargo.toml backend/daemon/
|
||
COPY backend/api/Cargo.toml backend/api/
|
||
RUN mkdir -p backend/daemon/src backend/api/src \
|
||
&& echo 'fn main(){}' > backend/daemon/src/main.rs \
|
||
&& echo 'fn main(){}' > backend/api/src/main.rs \
|
||
&& cargo fetch
|
||
COPY backend/ backend/
|
||
COPY database/ database/
|
||
RUN cargo build --release --bin loraweb-daemon
|
||
|
||
FROM debian:bookworm-slim
|
||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||
RUN useradd -r -u 1001 loraweb
|
||
COPY --from=builder /app/target/release/loraweb-daemon /usr/local/bin/
|
||
ENV CONFIG_PATH=/etc/loraweb/daemon-config
|
||
USER loraweb
|
||
CMD ["loraweb-daemon"]
|
||
```
|
||
|
||
- [ ] **Step 2: Create Rust Dockerfile (API)**
|
||
|
||
Same pattern as daemon but `--bin loraweb-api`.
|
||
|
||
- [ ] **Step 3: Create frontend Dockerfile**
|
||
|
||
```dockerfile
|
||
# frontend/Dockerfile
|
||
FROM node:20-alpine AS builder
|
||
WORKDIR /app
|
||
COPY package.json ./
|
||
RUN npm install --legacy-peer-deps
|
||
COPY . .
|
||
RUN npm run build
|
||
|
||
FROM nginx:alpine
|
||
RUN adduser -D -u 1001 loraweb
|
||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||
EXPOSE 80
|
||
```
|
||
|
||
- [ ] **Step 4: Create nginx.conf**
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location /api/ {
|
||
proxy_pass http://api:3001/api/;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
location / {
|
||
try_files $uri /index.html;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create .dockerignore**
|
||
|
||
```
|
||
target/
|
||
node_modules/
|
||
frontend/dist/
|
||
*.db
|
||
.git/
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/daemon/Dockerfile backend/api/Dockerfile frontend/Dockerfile frontend/nginx.conf .dockerignore
|
||
git commit -m "feat(infra): add multi-stage Dockerfiles for daemon, API, and frontend"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Docker Compose & Deploy Config
|
||
|
||
**Files:**
|
||
- Create: `deploy/docker-compose.yml`
|
||
- Create: `deploy/.env.example`
|
||
- Create: `deploy/loraweb.service`
|
||
- Modify: `.gitignore`
|
||
|
||
- [ ] **Step 1: Create docker-compose.yml**
|
||
|
||
```yaml
|
||
# deploy/docker-compose.yml
|
||
name: loraweb
|
||
|
||
services:
|
||
influxdb:
|
||
image: influxdb:2.7
|
||
volumes:
|
||
- loraweb-influx:/var/lib/influxdb2
|
||
environment:
|
||
- DOCKER_INFLUXDB_INIT_MODE=setup
|
||
- DOCKER_INFLUXDB_INIT_USERNAME=admin
|
||
- DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUX_ADMIN_PASSWORD:-changeme}
|
||
- DOCKER_INFLUXDB_INIT_ORG=${INFLUX_ORG:-loraweb}
|
||
- DOCKER_INFLUXDB_INIT_BUCKET=${INFLUX_BUCKET:-sensors}
|
||
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUX_TOKEN}
|
||
restart: unless-stopped
|
||
|
||
daemon:
|
||
build:
|
||
context: ..
|
||
dockerfile: backend/daemon/Dockerfile
|
||
ports:
|
||
- "8080:8080"
|
||
volumes:
|
||
- loraweb-data:/data
|
||
- ./daemon-config.toml:/etc/loraweb/daemon-config.toml:ro
|
||
environment:
|
||
- LORAWEB__DATABASE__PATH=/data/loraweb.db
|
||
- LORAWEB__INFLUX__URL=http://influxdb:8086
|
||
- LORAWEB__INFLUX__TOKEN=${INFLUX_TOKEN}
|
||
- LORAWEB__INFLUX__ORG=${INFLUX_ORG:-loraweb}
|
||
- LORAWEB__INFLUX__BUCKET=${INFLUX_BUCKET:-sensors}
|
||
- RUST_LOG=info
|
||
depends_on:
|
||
- influxdb
|
||
restart: unless-stopped
|
||
|
||
api:
|
||
build:
|
||
context: ..
|
||
dockerfile: backend/api/Dockerfile
|
||
volumes:
|
||
- loraweb-data:/data
|
||
environment:
|
||
- DATABASE_PATH=/data/loraweb.db
|
||
- PORT=3001
|
||
- JWT_SECRET=${JWT_SECRET}
|
||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||
- INFLUX_URL=http://influxdb:8086
|
||
- INFLUX_TOKEN=${INFLUX_TOKEN}
|
||
- INFLUX_ORG=${INFLUX_ORG:-loraweb}
|
||
- INFLUX_BUCKET=${INFLUX_BUCKET:-sensors}
|
||
- RUST_LOG=info
|
||
depends_on:
|
||
- influxdb
|
||
restart: unless-stopped
|
||
|
||
frontend:
|
||
build:
|
||
context: ../frontend
|
||
dockerfile: Dockerfile
|
||
ports:
|
||
- "80:80"
|
||
depends_on:
|
||
- api
|
||
restart: unless-stopped
|
||
|
||
volumes:
|
||
loraweb-data:
|
||
loraweb-influx:
|
||
```
|
||
|
||
- [ ] **Step 2: Create .env.example**
|
||
|
||
```bash
|
||
# deploy/.env.example
|
||
# Copy to .env and fill in values
|
||
|
||
# JWT secret — MUST be changed in production
|
||
JWT_SECRET=change-me-to-a-random-string
|
||
|
||
# Initial admin password — change after first login
|
||
ADMIN_PASSWORD=admin123
|
||
|
||
# InfluxDB
|
||
INFLUX_TOKEN=my-super-secret-token
|
||
INFLUX_ADMIN_PASSWORD=changeme
|
||
INFLUX_ORG=loraweb
|
||
INFLUX_BUCKET=sensors
|
||
```
|
||
|
||
- [ ] **Step 3: Create systemd service**
|
||
|
||
```ini
|
||
# deploy/loraweb.service
|
||
[Unit]
|
||
Description=LoRaWAN Web Portal
|
||
After=docker.service
|
||
Requires=docker.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
WorkingDirectory=/opt/loraweb/deploy
|
||
ExecStart=/usr/bin/docker compose up
|
||
ExecStop=/usr/bin/docker compose down
|
||
Restart=always
|
||
RestartSec=10
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
- [ ] **Step 4: Update .gitignore**
|
||
|
||
Add:
|
||
```
|
||
deploy/.env
|
||
deploy/daemon-config.toml
|
||
*.db
|
||
target/
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add deploy/ .gitignore
|
||
git commit -m "feat(infra): add Docker Compose, .env.example, and systemd service"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Final Integration Test
|
||
|
||
- [ ] **Step 1: Verify full workspace compiles**
|
||
|
||
Run: `cargo build --release`
|
||
|
||
- [ ] **Step 2: Verify frontend builds**
|
||
|
||
Run: `cd frontend && npm run build`
|
||
|
||
- [ ] **Step 3: Verify Docker Compose builds**
|
||
|
||
Run: `cd deploy && docker compose build`
|
||
|
||
- [ ] **Step 4: Verify Docker Compose starts**
|
||
|
||
Run: `cd deploy && cp .env.example .env && docker compose up -d`
|
||
Expected: All 4 services start, frontend reachable on port 80, daemon on 8080.
|
||
|
||
- [ ] **Step 5: Test end-to-end**
|
||
|
||
- Login at http://localhost with admin/admin123
|
||
- Dashboard shows 0 counts
|
||
- Send test uplink: `curl -X POST "http://localhost:8080/?event=up" -H "Content-Type: application/json" -d '{"time":"2026-03-19T12:00:00Z","deviceInfo":{"devEui":"test123","deviceName":"Test","applicationName":"Test"},"dr":5,"fCnt":1,"fPort":1,"rxInfo":[{"rssi":-80,"snr":9.5}],"object":{"temperature":22.5}}'`
|
||
- New device appears in Unknown Topics page
|
||
- Assign to a new sensor
|
||
- Sensor data page shows chart
|
||
|
||
- [ ] **Step 6: Clean up and final commit**
|
||
|
||
```bash
|
||
cd deploy && docker compose down
|
||
git add -A
|
||
git commit -m "feat: complete LoRaWAN Web Portal implementation"
|
||
```
|