epaper-display/LORAWEB_NEU/docs/superpowers/plans/2026-03-19-loraweb-implementation.md
Christian Mueller 3b9b8668c2 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) <noreply@anthropic.com>
2026-03-19 22:44:23 +01:00

3319 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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