mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
47a76c4bf8
commit
888f18603f
@@ -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,3 +1,4 @@
|
||||
pub mod device_events;
|
||||
pub mod devices;
|
||||
pub mod error;
|
||||
pub mod notifications;
|
||||
|
||||
@@ -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
@@ -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,2 +1,3 @@
|
||||
pub mod device_events;
|
||||
pub mod devices;
|
||||
pub mod notifications;
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(¶ms, "page_offset");
|
||||
let page_limit: Option<i64> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user