# 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" ```