From 888f18603f5f96508df064ec3b9890949f048f2d Mon Sep 17 00:00:00 2001 From: rzuasti Date: Wed, 27 May 2026 21:22:13 -0400 Subject: [PATCH] Add device_events table and scan history API endpoint Records a DeviceEvent row on every scan: NewDevice when a device is first seen, DeviceSeen on subsequent scans. Exposes GET /api/devices/{mac}/events with pagination, ordered by date descending. Co-Authored-By: Claude Sonnet 4.6 --- .../03-add_device_events/up.sql | 8 + backend/src/db.rs | 1 + backend/src/db/device_events.rs | 223 ++++++++++++++++++ backend/src/events.rs | 25 +- backend/src/model.rs | 1 + backend/src/model/device_events.rs | 111 +++++++++ backend/src/web_server.rs | 7 + backend/src/web_server/device_events.rs | 41 ++++ .../tests/database_setup/03-device_events.sql | 6 + 9 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 backend/database_migrations/03-add_device_events/up.sql create mode 100644 backend/src/db/device_events.rs create mode 100644 backend/src/model/device_events.rs create mode 100644 backend/src/web_server/device_events.rs create mode 100644 backend/tests/database_setup/03-device_events.sql diff --git a/backend/database_migrations/03-add_device_events/up.sql b/backend/database_migrations/03-add_device_events/up.sql new file mode 100644 index 0000000..8000108 --- /dev/null +++ b/backend/database_migrations/03-add_device_events/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE device_events( + id INTEGER PRIMARY KEY, + mac_address TEXT NOT NULL, + created_on TEXT NOT NULL, + event_type TEXT NOT NULL, + ipv4_address TEXT NOT NULL, + vendor TEXT NOT NULL +); diff --git a/backend/src/db.rs b/backend/src/db.rs index a24d84b..c98f09b 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,3 +1,4 @@ +pub mod device_events; pub mod devices; pub mod error; pub mod notifications; diff --git a/backend/src/db/device_events.rs b/backend/src/db/device_events.rs new file mode 100644 index 0000000..7677022 --- /dev/null +++ b/backend/src/db/device_events.rs @@ -0,0 +1,223 @@ +use crate::db; +use crate::db::error::DbError; +use log::{debug, error}; +use rusqlite::{params, params_from_iter}; + +use crate::model::device_events::DeviceEvent; + +pub fn insert(event: DeviceEvent) -> Result { + let conn = db::get_db_connection(); + + match conn.execute( + "INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + event.mac_address, + event.created_on, + event.event_type, + event.ipv4_address, + event.vendor + ], + ) { + Ok(_) => { + debug!("Device event inserted into database: {}", event); + Ok(conn.last_insert_rowid()) + } + Err(error) => { + error!("Error inserting device event ({event}) into database: {error}"); + Err(DbError::from(error)) + } + } +} + +pub fn list( + mac_address: Option, + page_offset: Option, + page_limit: Option, +) -> Result, DbError> { + debug!("Listing device events"); + let conn = db::get_db_connection(); + + let mut sql_statement = + "SELECT id, mac_address, created_on, event_type, ipv4_address, vendor FROM device_events WHERE 1=1" + .to_string(); + + let mut params: Vec = Vec::new(); + + if let Some(mac) = mac_address { + debug!("Adding filter mac_address={}", mac); + sql_statement.push_str(" AND mac_address=?"); + params.push(mac.into()); + } + + sql_statement.push_str(" ORDER BY created_on DESC"); + + if let (Some(page_offset), Some(page_limit)) = (page_offset, page_limit) { + debug!( + "Adding paging with offset={} and limit={}", + page_offset, page_limit + ); + sql_statement.push_str(" LIMIT ? OFFSET ?"); + params.push(page_limit.into()); + params.push(page_offset.into()); + } + + let mut stmt = conn.prepare(sql_statement.as_str())?; + + let events: Vec = stmt + .query_map(params_from_iter(params.iter()), |row| { + Ok(DeviceEvent { + id: row.get(0)?, + mac_address: row.get(1)?, + created_on: row.get(2)?, + event_type: row.get(3)?, + ipv4_address: row.get(4)?, + vendor: row.get(5)?, + }) + })? + .collect::>()?; + + Ok(events) +} + +#[cfg(test)] +fn read(id: i64) -> Option { + let conn = db::get_db_connection(); + + let result: Result = conn.query_one( + "SELECT id, mac_address, created_on, event_type, ipv4_address, vendor FROM device_events WHERE id=?1", + params![id], + |row| { + Ok(DeviceEvent { + id: row.get(0)?, + mac_address: row.get(1)?, + created_on: row.get(2)?, + event_type: row.get(3)?, + ipv4_address: row.get(4)?, + vendor: row.get(5)?, + }) + }, + ); + + match result { + Ok(value) => Some(value), + Err(error) => { + match error { + rusqlite::Error::QueryReturnedNoRows => { + debug!("No device event found for id={id}.") + } + _ => error!("Error reading device event from database: {error}"), + }; + None + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::{model::device_events::DeviceEventType, tests_common}; + + #[tokio::test] + async fn test_insert() { + tests_common::setup().await; + + let created_on = Utc::now(); + let inserted_id = insert(DeviceEvent::new( + "aa:aa:aa:aa:aa:aa".to_string(), + created_on, + DeviceEventType::NewDevice, + "192.168.0.1".to_string(), + "Vendor 1".to_string(), + )) + .unwrap(); + + assert!(inserted_id >= 0, "Inserted device event id should be positive"); + + let event = read(inserted_id).unwrap(); + assert_eq!(event.mac_address, "aa:aa:aa:aa:aa:aa"); + assert_eq!(event.created_on, created_on); + assert_eq!(event.event_type, DeviceEventType::NewDevice); + assert_eq!(event.ipv4_address, "192.168.0.1"); + assert_eq!(event.vendor, "Vendor 1"); + + let inserted_id = insert(DeviceEvent::new( + "bb:bb:bb:bb:bb:bb".to_string(), + Utc::now(), + DeviceEventType::DeviceSeen, + "192.168.0.2".to_string(), + "Vendor 2".to_string(), + )) + .unwrap(); + + let event = read(inserted_id).unwrap(); + assert_eq!(event.event_type, DeviceEventType::DeviceSeen); + } + + #[tokio::test] + async fn test_list() { + tests_common::setup().await; + + let events = list(None, None, None).unwrap(); + assert!( + events.len() >= 3, + "There should be at least 3 device events from seed data" + ); + + // Verify ordering: created_on DESC — first element should be newest + if events.len() >= 2 { + assert!( + events[0].created_on >= events[1].created_on, + "Events should be ordered by created_on DESC" + ); + } + + let event3 = events.iter().find(|e| e.id == 3).unwrap(); + assert_eq!(event3.mac_address, "aa:aa:aa:aa:aa:aa"); + assert_eq!(event3.event_type, DeviceEventType::DeviceSeen); + } + + #[tokio::test] + async fn test_list_filter_by_mac_address() { + tests_common::setup().await; + + let events = list(Some("aa:aa:aa:aa:aa:aa".to_string()), None, None).unwrap(); + assert!( + events.len() >= 2, + "There should be at least 2 events for aa:aa:aa:aa:aa:aa" + ); + for event in &events { + assert_eq!( + event.mac_address, "aa:aa:aa:aa:aa:aa", + "All returned events should belong to mac_address aa:aa:aa:aa:aa:aa" + ); + } + + let events = list(Some("zz:zz:zz:zz:zz:zz".to_string()), None, None).unwrap(); + assert!(events.is_empty(), "Unknown MAC should return empty list"); + } + + #[tokio::test] + async fn test_list_pagination() { + tests_common::setup().await; + + let first_page = list(None, Some(0), Some(2)).unwrap(); + assert_eq!(first_page.len(), 2, "First page should have 2 events"); + + let second_page = list(None, Some(2), Some(2)).unwrap(); + assert!( + second_page.len() >= 1, + "Second page should have at least 1 event" + ); + + let first_ids: Vec = first_page.iter().map(|e| e.id).collect(); + for event in &second_page { + assert!( + !first_ids.contains(&event.id), + "Event id={} should not appear in both pages", + event.id + ); + } + } +} diff --git a/backend/src/events.rs b/backend/src/events.rs index e721c60..52490c0 100644 --- a/backend/src/events.rs +++ b/backend/src/events.rs @@ -5,13 +5,14 @@ use std::error::Error; use std::time::Duration; use crate::db; +use crate::model::device_events::{DeviceEvent, DeviceEventType}; use crate::model::devices::Device; use crate::model::notifications::Notification; use crate::model::notifications::NotificationType; use crate::settings::get_settings; use chrono::{Local, Utc}; use duration_string::DurationString; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; // Private helper function to deliver messages fn send_notification(notification: Notification) -> Result<(), Box> { @@ -34,6 +35,17 @@ fn send_notification(notification: Notification) -> Result<(), Box> { } pub fn trigger_new_device(device: Device) -> Result<(), Box> { + let event = DeviceEvent::new( + device.mac_address.clone(), + Utc::now(), + DeviceEventType::NewDevice, + device.ipv4_address.clone(), + device.vendor.clone(), + ); + if let Err(err) = db::device_events::insert(event) { + error!("Failed to record device event for {}: {}", device.mac_address, err); + } + let notification = Notification::new( Utc::now(), NotificationType::NewDeviceFound, @@ -54,6 +66,17 @@ pub fn trigger_existing_device( existing_device: Device, new_device: Device, ) -> Result<(), Box> { + let event = DeviceEvent::new( + new_device.mac_address.clone(), + Utc::now(), + DeviceEventType::DeviceSeen, + new_device.ipv4_address.clone(), + new_device.vendor.clone(), + ); + if let Err(err) = db::device_events::insert(event) { + error!("Failed to record device event for {}: {}", new_device.mac_address, err); + } + // Notify if the device comes back online after not being seen for the configured period let elapsed_since_last_seen: Duration = (Local::now().to_utc() - existing_device.last_seen) .to_std() diff --git a/backend/src/model.rs b/backend/src/model.rs index d41b543..a346df2 100644 --- a/backend/src/model.rs +++ b/backend/src/model.rs @@ -1,2 +1,3 @@ +pub mod device_events; pub mod devices; pub mod notifications; diff --git a/backend/src/model/device_events.rs b/backend/src/model/device_events.rs new file mode 100644 index 0000000..122bc2d --- /dev/null +++ b/backend/src/model/device_events.rs @@ -0,0 +1,111 @@ +use crate::utils::date_serializer; +use chrono::{DateTime, Utc}; +use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; +use serde::{Deserialize, Serialize}; +use std::{error::Error, fmt, str::FromStr}; +use utoipa::ToSchema; + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct DeviceEvent { + pub id: i64, + pub mac_address: String, + #[serde(with = "date_serializer")] + #[schema(value_type = String, format = DateTime)] + pub created_on: DateTime, + pub event_type: DeviceEventType, + pub ipv4_address: String, + pub vendor: String, +} + +impl DeviceEvent { + pub fn new( + mac_address: String, + created_on: DateTime, + event_type: DeviceEventType, + ipv4_address: String, + vendor: String, + ) -> Self { + Self { + id: -1, + mac_address, + created_on, + event_type, + ipv4_address, + vendor, + } + } +} + +impl fmt::Display for DeviceEvent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "id={}, mac_address={}, created_on={}, event_type={}, ipv4_address={}, vendor={}", + self.id, + self.mac_address, + self.created_on, + self.event_type, + self.ipv4_address, + self.vendor, + ) + } +} + +impl PartialEq for DeviceEvent { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)] +pub enum DeviceEventType { + NewDevice, + DeviceSeen, +} + +impl fmt::Display for DeviceEventType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::NewDevice => write!(f, "NewDevice"), + Self::DeviceSeen => write!(f, "DeviceSeen"), + } + } +} + +#[derive(Debug)] +pub struct DeviceEventTypeParseError; + +impl fmt::Display for DeviceEventTypeParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Error parsing device event type") + } +} + +impl Error for DeviceEventTypeParseError {} + +impl FromStr for DeviceEventType { + type Err = DeviceEventTypeParseError; + + fn from_str(s: &str) -> Result { + match s { + "NewDevice" => Ok(DeviceEventType::NewDevice), + "DeviceSeen" => Ok(DeviceEventType::DeviceSeen), + _ => Err(DeviceEventTypeParseError), + } + } +} + +impl ToSql for DeviceEventType { + fn to_sql(&self) -> rusqlite::Result> { + Ok(self.to_string().into()) + } +} + +impl FromSql for DeviceEventType { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + value + .as_str()? + .parse() + .map_err(|e| FromSqlError::Other(Box::new(e))) + } +} diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 93f616b..00b4ae2 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -1,5 +1,6 @@ use std::error::Error; +use crate::model::device_events::{DeviceEvent, DeviceEventType}; use crate::model::devices::Device; use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; @@ -20,6 +21,7 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::Modify; use utoipa_swagger_ui::SwaggerUi; +pub mod device_events; pub mod devices; pub mod notifications; pub mod utils; @@ -42,17 +44,21 @@ pub mod utils; notifications::read_without_flagging, notifications::mark_as_new, notifications::mark_all_as_old, + device_events::list, ), components(schemas( Device, Notification, NotificationType, RegisterDevicePayload, + DeviceEvent, + DeviceEventType, )), modifiers(&SecurityAddon), tags( (name = "devices", description = "Device management"), (name = "notifications", description = "Notification management"), + (name = "device_events", description = "Device event history"), ) )] struct ApiDoc; @@ -94,6 +100,7 @@ pub async fn serve() -> Result<(), Box> { .route("/api/devices", put(devices::register)) .route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", get(devices::read)) + .route("/api/devices/{mac_address}/events", get(device_events::list)) .route("/api/notifications", get(notifications::list)) .route( "/api/notifications/mark_all_as_old", diff --git a/backend/src/web_server/device_events.rs b/backend/src/web_server/device_events.rs new file mode 100644 index 0000000..6fae489 --- /dev/null +++ b/backend/src/web_server/device_events.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +use axum::{ + Json, + extract::{Path, Query}, + http::StatusCode, +}; +use log::error; + +use crate::{db, model::device_events::DeviceEvent, web_server::utils}; + +#[utoipa::path( + get, + path = "/api/devices/{mac_address}/events", + tag = "device_events", + params( + ("mac_address" = String, Path, description = "MAC address of the device"), + ("page_offset" = Option, Query, description = "Pagination offset"), + ("page_limit" = Option, Query, description = "Maximum number of results to return"), + ), + responses( + (status = 200, description = "List of device events", body = Vec), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn list( + Path(mac_address): Path, + Query(params): Query>, +) -> Result>, StatusCode> { + let page_offset: Option = utils::parse_parameter_int(¶ms, "page_offset"); + let page_limit: Option = utils::parse_parameter_int(¶ms, "page_limit"); + + match db::device_events::list(Some(mac_address), page_offset, page_limit) { + Ok(value) => Ok(Json(value)), + Err(err) => { + error!("Error listing device events: {}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} diff --git a/backend/tests/database_setup/03-device_events.sql b/backend/tests/database_setup/03-device_events.sql new file mode 100644 index 0000000..19b9e58 --- /dev/null +++ b/backend/tests/database_setup/03-device_events.sql @@ -0,0 +1,6 @@ +INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor) + VALUES (1, 'aa:aa:aa:aa:aa:aa', '2026-01-01 11:11:11', 'NewDevice', '192.168.0.1', 'Vendor 1'); +INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor) + VALUES (2, 'bb:bb:bb:bb:bb:bb', '2026-02-03 13:14:15', 'DeviceSeen', '192.168.0.2', 'Vendor 2'); +INSERT INTO device_events (id, mac_address, created_on, event_type, ipv4_address, vendor) + VALUES (3, 'aa:aa:aa:aa:aa:aa', '2026-03-10 09:00:00', 'DeviceSeen', '192.168.0.1', 'Vendor 1');