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>
93 KiB
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
[workspace]
members = ["backend/daemon", "backend/api"]
resolver = "2"
- Step 2: Create daemon Cargo.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)
#[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
[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)
#[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
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
-- 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
// 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
// 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:
#[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
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
# 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
// 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
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
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)]):
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:
#[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)
// 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
// 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
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
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
// 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:
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
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:
// 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
// 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:
// 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
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
// 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
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
// 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.
// 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
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
// 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
// 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
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
// 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:
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
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:
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:
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
}
}
- Step 2: Create vite.config.ts
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
{
"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
<!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
@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
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)
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
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
// 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
// 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
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
// 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
// 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
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
// 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
// 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
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
// 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
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:
// 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:
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 CRUDSensorTable(desktop) /SensorCards(mobile) — list displaySensorFormModal— create/edit form with topic assignmentDataModal— two tabs, full-screen on mobile (bottom-sheet pattern:fixed inset-0 flex items-end sm:items-center)- Tab "Diagramme":
ChartPanelcomponents, one per uniquediagramnumber - Tab "Einrichten":
FieldConfigRowper field with inputs for label, unit, type, panel
- Tab "Diagramme":
Chart panel grouping algorithm:
// 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:
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):
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
diagramnumber 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
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
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)
# 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
# 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
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
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
# 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
# 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
# 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
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
cd deploy && docker compose down
git add -A
git commit -m "feat: complete LoRaWAN Web Portal implementation"