From 3b9b8668c2615872032943882a45ee578f8475a3 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Thu, 19 Mar 2026 22:44:23 +0100 Subject: [PATCH] Add implementation plan for LoRaWAN Web Portal 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) --- .../2026-03-19-loraweb-implementation.md | 3318 +++++++++++++++++ 1 file changed, 3318 insertions(+) create mode 100644 LORAWEB_NEU/docs/superpowers/plans/2026-03-19-loraweb-implementation.md diff --git a/LORAWEB_NEU/docs/superpowers/plans/2026-03-19-loraweb-implementation.md b/LORAWEB_NEU/docs/superpowers/plans/2026-03-19-loraweb-implementation.md new file mode 100644 index 0000000..97521f5 --- /dev/null +++ b/LORAWEB_NEU/docs/superpowers/plans/2026-03-19-loraweb-implementation.md @@ -0,0 +1,3318 @@ +# 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 { + 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://:{}/ ", + 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, + rssi: Option, + snr: Option, + dr: Option, + f_cnt: Option, + f_port: Option, + ) -> Result<(), Box> { + 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, +} + +#[derive(Deserialize)] +struct ChirpStackUplink { + time: Option, + #[serde(rename = "deviceInfo")] + device_info: Option, + dr: Option, + #[serde(rename = "fCnt")] + f_cnt: Option, + #[serde(rename = "fPort")] + f_port: Option, + #[serde(rename = "rxInfo")] + rx_info: Option>, + object: Option>, +} + +#[derive(Deserialize)] +struct DeviceInfo { + #[serde(rename = "devEui")] + dev_eui: String, + #[serde(rename = "deviceName")] + device_name: Option, + #[serde(rename = "applicationName")] + application_name: Option, +} + +#[derive(Deserialize)] +struct RxInfo { + rssi: Option, + snr: Option, +} + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/", post(handle_event)) + .with_state(state) +} + +async fn handle_event( + State(state): State>, + Query(query): Query, + Json(body): Json, +) -> 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://:{}/", + 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, 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`. 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> { + 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 { + 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, // "totp" for temporary token +} + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, + totp_token: Option, + totp_code: Option, +} + +#[derive(Serialize)] +struct LoginResponse { + #[serde(skip_serializing_if = "Option::is_none")] + token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + totp_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + totp_token: Option, +} + +pub fn routes() -> Router { + Router::new() + .route("/auth/login", post(login)) +} + +async fn login( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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)> = 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)> { + 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 { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(data.claims) +} + +/// Middleware to require authentication +pub async fn require_auth( + State(state): State, + mut req: Request, + 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, + next: Next, +) -> Response { + let claims = req.extensions().get::(); + 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, + display_config: String, + created_at: String, +} + +#[derive(Deserialize)] +pub struct CreateSensor { + name: String, + description: Option, + location: Option, + sensor_type: Option, + topic_id: Option, +} + +#[derive(Deserialize)] +pub struct UpdateSensor { + name: Option, + description: Option, + location: Option, + sensor_type: Option, + active: Option, + topic_id: Option, +} + +#[derive(Deserialize)] +pub struct DataQuery { + hours: Option, +} + +pub fn routes() -> Router { + 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, + 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, +} + +#[derive(Deserialize)] +pub struct UpdateSensor { + name: Option, + description: Option, + location: Option, + sensor_type: Option, + active: Option, + topic_id: Option, +} + +#[derive(Deserialize)] +pub struct DataQuery { + hours: Option, +} + +pub fn routes() -> Router { + 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, +) -> Result>, 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, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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, + Path(id): Path, +) -> Result, 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, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Path(id): Path, +) -> Result { + // 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,)> = 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, + Path(id): Path, + Query(params): Query, +) -> Result>, (StatusCode, Json)> { + 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,)> = 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 { + 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::() { + 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, + Path(id): Path, +) -> Result, 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, + Path(id): Path, + Json(config): Json, +) -> Result { + 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, + alarm_status: String, + alarm_threshold_hours: f64, + created_at: String, +} + +#[derive(Deserialize)] +pub struct UpdateTopic { + approved: Option, + sensor_id: Option, // assign to sensor +} + +#[derive(Deserialize)] +pub struct ThresholdUpdate { + threshold_minutes: f64, +} + +pub fn routes() -> Router { + 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, +) -> Result>, 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, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Path(id): Path, + Json(req): Json, +) -> Result { + 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, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AlarmEntry { + topic_id: String, + dev_eui: String, + alarm_status: String, + last_seen: Option, + sensor_name: Option, +} + +pub fn routes() -> Router { + Router::new() + .route("/alarms", get(get_alarms)) +} + +async fn get_alarms( + State(state): State, +) -> Result, 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, +} + +#[derive(Deserialize)] +pub struct UpdateUser { + username: Option, + password: Option, + role: Option, +} + +pub fn routes() -> Router { + 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, +) -> Result>, 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, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Path(id): Path, +) -> Result { + 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, + Path(id): Path, +) -> Result, 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, + Path(id): Path, +) -> Result { + 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 + + + + + + LoRaWEB + + +
+ + + +``` + +- [ ] **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( + + + +) +``` + +- [ ] **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 ( + + + + LoRaWEB - Coming Soon} /> + + + ) +} + +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(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState( + () => localStorage.getItem('loraweb_token') + ) + const [user, setUser] = useState(() => { + 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 ( + + {children} + + ) +} + +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 = () => ( + + ) + + return ( +
+ {/* Mobile top bar */} +
+ + LoRaWEB +
+
+ + {/* Mobile drawer */} + {drawerOpen && ( +
+
setDrawerOpen(false)} /> +
+
+ LoRaWEB + +
+ +
+ +
+
+
+ )} + +
+ {/* Desktop sidebar */} + + + {/* Main content */} +
+ {children} +
+
+
+ ) +} +``` + +- [ ] **Step 2: Create StatusBadge.tsx** + +```tsx +// frontend/src/components/StatusBadge.tsx +interface Props { + status: string +} + +const colors: Record = { + 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 ( + + {status} + + ) +} +``` + +- [ ] **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(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 ( +
+
+
+ +

LoRaWEB

+
+ + {!totpToken ? ( + <> + 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 + /> + 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" + /> + + ) : ( + 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 + /> + )} + + +
+
+ ) +} +``` + +- [ ] **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 + if (admin && !isAdmin) return + return {children} +} + +function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App +``` + +- [ ] **Step 3: Create placeholder page files** + +Create stub files for DashboardPage, SensorsPage, UnknownTopicsPage, AlarmsPage, UsersPage — each exporting a simple `
Page Name
` 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(null) + + const load = () => getAlarms().then(r => setData(r.data)).catch(() => {}) + + useEffect(() => { + load() + const timer = setInterval(load, 15000) + return () => clearInterval(timer) + }, []) + + if (!data) return
Laden...
+ + 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 ( +
+

Dashboard

+ +
+ {tiles.map(t => ( +
+ +
{t.count}
+
{t.label}
+
+ ))} +
+ + {data.alarms.length > 0 && ( +
+

Aktive Alarme

+
+ {data.alarms.map(a => ( +
+
+
{a.sensor_name || a.dev_eui}
+
{a.dev_eui}
+
+
+ + {a.last_seen ? new Date(a.last_seen).toLocaleString('de-DE') : 'Nie gesehen'} + + +
+
+ ))} +
+
+ )} +
+ ) +} +``` + +- [ ] **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 +``` + +**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) +// Render one 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>) +``` + +**All pages must include auto-refresh (15s):** +```typescript +useEffect(() => { + loadData() + const timer = setInterval(loadData, 15000) + return () => clearInterval(timer) +}, []) +``` + +Key patterns: +- `connectNulls={true}` on `` components +- Frontend aggregation for bar charts (group by date/week, sum values) +- Panel grouping: fields with same `diagram` number rendered in one `` +- 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" +```