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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 21:22:13 -04:00
co-authored by Claude Sonnet 4.6
parent 47a76c4bf8
commit 888f18603f
9 changed files with 422 additions and 1 deletions
@@ -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
);
+1
View File
@@ -1,3 +1,4 @@
pub mod device_events;
pub mod devices;
pub mod error;
pub mod notifications;
+223
View File
@@ -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<i64, DbError> {
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<String>,
page_offset: Option<i64>,
page_limit: Option<i64>,
) -> Result<Vec<DeviceEvent>, 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<rusqlite::types::Value> = 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<DeviceEvent> = 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::<Result<_, _>>()?;
Ok(events)
}
#[cfg(test)]
fn read(id: i64) -> Option<DeviceEvent> {
let conn = db::get_db_connection();
let result: Result<DeviceEvent, rusqlite::Error> = 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<i64> = 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
);
}
}
}
+24 -1
View File
@@ -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<dyn Error>> {
@@ -34,6 +35,17 @@ fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
}
pub fn trigger_new_device(device: Device) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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()
+1
View File
@@ -1,2 +1,3 @@
pub mod device_events;
pub mod devices;
pub mod notifications;
+111
View File
@@ -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<Utc>,
pub event_type: DeviceEventType,
pub ipv4_address: String,
pub vendor: String,
}
impl DeviceEvent {
pub fn new(
mac_address: String,
created_on: DateTime<Utc>,
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<Self, Self::Err> {
match s {
"NewDevice" => Ok(DeviceEventType::NewDevice),
"DeviceSeen" => Ok(DeviceEventType::DeviceSeen),
_ => Err(DeviceEventTypeParseError),
}
}
}
impl ToSql for DeviceEventType {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.to_string().into())
}
}
impl FromSql for DeviceEventType {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
value
.as_str()?
.parse()
.map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
+7
View File
@@ -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<dyn Error>> {
.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",
+41
View File
@@ -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<i64>, Query, description = "Pagination offset"),
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
),
responses(
(status = 200, description = "List of device events", body = Vec<DeviceEvent>),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Path(mac_address): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<DeviceEvent>>, StatusCode> {
let page_offset: Option<i64> = utils::parse_parameter_int(&params, "page_offset");
let page_limit: Option<i64> = utils::parse_parameter_int(&params, "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)
}
}
}
@@ -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');