Add device detail screen and navigate to it from notifications and devices

- New /devices/:macAddress route showing all device fields with Register/Forget actions
- Device list items are now tappable; popup menu gains a "View details" item
- Notification list items with a linked device are tappable; popup menu gains a "View device" item
- Backend: new DB migration adds optional mac_address column to notifications
- Device-related notifications (NewDeviceFound, DeviceOnlineAfterTime, DeviceChanged) now store the triggering device's MAC address

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 18:24:35 -04:00
co-authored by Claude Sonnet 4.6
parent 07f7d54531
commit 2da339a2b0
10 changed files with 472 additions and 15 deletions
@@ -0,0 +1 @@
ALTER TABLE notifications ADD COLUMN mac_address TEXT;
+35 -4
View File
@@ -14,7 +14,7 @@ pub fn list(
let conn = db::get_db_connection();
let mut sql_statement =
"SELECT id, created_on, notification_type, title, body, is_new FROM notifications WHERE 1=1"
"SELECT id, created_on, notification_type, title, body, is_new, mac_address FROM notifications WHERE 1=1"
.to_string();
let mut params: Vec<rusqlite::types::Value> = Vec::new();
@@ -53,6 +53,7 @@ pub fn list(
title: row.get(3)?,
body: row.get(4)?,
is_new: row.get(5)?,
mac_address: row.get(6)?,
})
})?
.collect::<Result<_, _>>()?;
@@ -64,8 +65,8 @@ pub fn insert(notification: Notification) -> Result<i64, DbError> {
let conn = db::get_db_connection();
match conn.execute(
"INSERT INTO notifications (created_on, notification_type, title, body, is_new) VALUES (?1, ?2, ?3, ?4, ?5)",
params![notification.created_on, notification.notification_type, notification.title, notification.body, notification.is_new]) {
"INSERT INTO notifications (created_on, notification_type, title, body, is_new, mac_address) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![notification.created_on, notification.notification_type, notification.title, notification.body, notification.is_new, notification.mac_address]) {
Ok(_) => {
debug!("Notification inserted into database: {}", notification);
Ok(conn.last_insert_rowid())
@@ -126,7 +127,7 @@ pub fn read(id: i64) -> Option<Notification> {
let conn = db::get_db_connection();
let result: Result<Notification, rusqlite::Error> = conn.query_one(
"SELECT id, created_on, notification_type, title, body, is_new FROM notifications WHERE id=?1",
"SELECT id, created_on, notification_type, title, body, is_new, mac_address FROM notifications WHERE id=?1",
params![id],
|row| {
Ok(Notification {
@@ -136,6 +137,7 @@ pub fn read(id: i64) -> Option<Notification> {
title: row.get(3)?,
body: row.get(4)?,
is_new: row.get(5)?,
mac_address: row.get(6)?,
})
},
);
@@ -176,6 +178,7 @@ mod tests {
"New notification title".to_string(),
"New notification body".to_string(),
true,
None,
))
.unwrap();
@@ -206,6 +209,7 @@ mod tests {
"New notification title".to_string(),
"New notification body".to_string(),
false,
None,
))
.unwrap();
@@ -232,6 +236,7 @@ mod tests {
"New notification 1".to_string(),
"Body 1".to_string(),
true,
None,
))
.unwrap();
@@ -241,6 +246,7 @@ mod tests {
"New notification 2".to_string(),
"Body 2".to_string(),
true,
None,
))
.unwrap();
@@ -275,6 +281,7 @@ mod tests {
"New notification title".to_string(),
"New notification body".to_string(),
true,
None,
))
.unwrap();
@@ -306,6 +313,28 @@ mod tests {
inserted_notification.body, "New notification body",
"Wrong body (should be 'New notification body')"
);
assert_eq!(
inserted_notification.mac_address, None,
"mac_address should be None"
);
// Insert and validate notification with mac_address
let mac = "aa:aa:aa:aa:aa:aa".to_string();
let inserted_id = insert(Notification::new(
Utc::now(),
NotificationType::NewDeviceFound,
"Device found".to_string(),
"Body".to_string(),
true,
Some(mac.clone()),
))
.unwrap();
let inserted_notification = read(inserted_id).unwrap();
assert_eq!(
inserted_notification.mac_address,
Some(mac),
"mac_address should round-trip through insert/read"
);
// Insert and validate notification without title nor body
let created_on = Utc::now();
@@ -315,6 +344,7 @@ mod tests {
"".to_string(),
"".to_string(),
false,
None,
))
.unwrap();
@@ -398,6 +428,7 @@ mod tests {
"New notification title".to_string(),
"New notification body".to_string(),
true,
None,
))
.unwrap();
+5
View File
@@ -43,6 +43,7 @@ pub fn trigger_new_device(device: Device) -> Result<(), Box<dyn Error>> {
device.mac_address, device.ipv4_address, device.vendor
),
true,
Some(device.mac_address.clone()),
);
send_notification(notification)?;
@@ -75,6 +76,7 @@ pub fn trigger_existing_device(
)))
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
@@ -97,6 +99,7 @@ pub fn trigger_existing_device(
new_device.vendor,
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
@@ -113,6 +116,7 @@ pub fn trigger_existing_device(
new_device.ipv4_address,
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
@@ -129,6 +133,7 @@ pub fn trigger_existing_device(
new_device.vendor,
),
true,
Some(new_device.mac_address.clone()),
);
send_notification(notification)?;
+5 -2
View File
@@ -15,6 +15,7 @@ pub struct Notification {
pub title: String,
pub body: String,
pub is_new: bool,
pub mac_address: Option<String>,
}
impl Notification {
@@ -24,6 +25,7 @@ impl Notification {
title: String,
body: String,
is_new: bool,
mac_address: Option<String>,
) -> Self {
Self {
id: -1,
@@ -32,6 +34,7 @@ impl Notification {
title,
body,
is_new,
mac_address,
}
}
}
@@ -40,8 +43,8 @@ impl fmt::Display for Notification {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"id={}, created_on={}, notification_type={}, is_new={}\ntitle={}\nbody={}",
self.id, self.created_on, self.notification_type, self.is_new, self.title, self.body
"id={}, created_on={}, notification_type={}, is_new={}, mac_address={:?}\ntitle={}\nbody={}",
self.id, self.created_on, self.notification_type, self.is_new, self.mac_address, self.title, self.body
)
}
}