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

93 KiB
Raw Permalink Blame History

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 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:

// 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 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
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"