diff --git a/TODO.md b/TODO.md index ee5b05d..955b2cc 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ ## Backend -- [ ] In the active scanners status, it should count the number of distinct devices it saw on the last scan (avoid duplicates) +- [x] In the active scanners status, it should count the number of distinct devices it saw on the last scan (avoid duplicates) - [ ] Implement the pushover API call directly to support HTML content and review notification text to use it ## Frontend diff --git a/backend/src/scanners/arp/scanner.rs b/backend/src/scanners/arp/scanner.rs index b2e9ff8..3636495 100644 --- a/backend/src/scanners/arp/scanner.rs +++ b/backend/src/scanners/arp/scanner.rs @@ -21,7 +21,7 @@ pub async fn scan() -> Result<(), Box> { info!("Done with ARP probes"); info!("Found {} online devices", devices.len()); - status::record_scan(devices.len() as u64); + status::record_scan(&devices); // Process found devices for device in devices.iter() { diff --git a/backend/src/scanners/arp/status.rs b/backend/src/scanners/arp/status.rs index 77e260e..912bb6b 100644 --- a/backend/src/scanners/arp/status.rs +++ b/backend/src/scanners/arp/status.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use once_cell::sync::OnceCell; use std::sync::Mutex; +use crate::model::devices::Device; use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus}; static STATUS: OnceCell> = OnceCell::new(); @@ -22,9 +23,9 @@ pub fn set_waiting(next_scan_at: DateTime) { } } -pub fn record_scan(devices_seen: u64) { +pub fn record_scan(devices: &[Device]) { if let Some(m) = STATUS.get() { - m.lock().unwrap().record_scan(devices_seen); + m.lock().unwrap().record_scan(devices); } } diff --git a/backend/src/scanners/common/active_status.rs b/backend/src/scanners/common/active_status.rs index 0c2b134..db30ed4 100644 --- a/backend/src/scanners/common/active_status.rs +++ b/backend/src/scanners/common/active_status.rs @@ -1,4 +1,6 @@ +use crate::model::devices::Device; use chrono::{DateTime, Utc}; +use std::collections::HashSet; /// Status state for the active (polling) scanners — ARP and SNMP. Each scanner owns its own /// `OnceCell>` and delegates to these methods (see e.g. @@ -41,9 +43,16 @@ impl ActiveStatus { self.next_scan_at = Some(next_scan_at); } - /// Record the result of a completed scan. - pub fn record_scan(&mut self, devices_seen: u64) { - self.last_scan_devices_seen = Some(devices_seen); + /// Record the result of a completed scan. A single scan can surface the same device more than + /// once — duplicate ARP replies, or one MAC bound to several IPs in an SNMP ARP cache — so the + /// reported count is the number of distinct devices (by MAC address), not raw sightings. + pub fn record_scan(&mut self, devices: &[Device]) { + let distinct = devices + .iter() + .map(|device| &device.mac_address) + .collect::>() + .len(); + self.last_scan_devices_seen = Some(distinct as u64); self.last_scan_at = Some(Utc::now()); } @@ -83,15 +92,37 @@ mod tests { assert_eq!(snapshot.next_scan_at.unwrap(), next); } + fn device_with_mac(mac: &str, ip: &str) -> Device { + Device::new(mac.to_string(), ip.to_string(), String::new(), Utc::now()) + } + #[test] fn record_scan_stores_count_and_time() { let mut status = ActiveStatus::new(); - status.record_scan(7); + let devices = vec![ + device_with_mac("aa:bb:cc:dd:ee:01", "192.168.0.2"), + device_with_mac("aa:bb:cc:dd:ee:02", "192.168.0.3"), + ]; + status.record_scan(&devices); let snapshot = status.snapshot(); - assert_eq!(snapshot.last_scan_devices_seen, Some(7)); + assert_eq!(snapshot.last_scan_devices_seen, Some(2)); assert!(snapshot.last_scan_at.is_some()); } + #[test] + fn record_scan_counts_distinct_devices() { + let mut status = ActiveStatus::new(); + // Same MAC seen twice (e.g. duplicate ARP reply or one MAC on two IPs) plus a second + // distinct device — only two unique devices should be reported. + let devices = vec![ + device_with_mac("aa:bb:cc:dd:ee:01", "192.168.0.2"), + device_with_mac("aa:bb:cc:dd:ee:01", "192.168.0.9"), + device_with_mac("aa:bb:cc:dd:ee:02", "192.168.0.3"), + ]; + status.record_scan(&devices); + assert_eq!(status.snapshot().last_scan_devices_seen, Some(2)); + } + #[test] fn initial_state_is_empty() { let snapshot = ActiveStatus::new().snapshot(); diff --git a/backend/src/scanners/snmp/scanner.rs b/backend/src/scanners/snmp/scanner.rs index 732860a..eb657d7 100644 --- a/backend/src/scanners/snmp/scanner.rs +++ b/backend/src/scanners/snmp/scanner.rs @@ -32,7 +32,7 @@ pub async fn scan() -> Result<(), Box> { match finder::find(config).await { Ok(devices) => { info!("SNMP poll found {} devices in the ARP cache", devices.len()); - status::record_scan(devices.len() as u64); + status::record_scan(&devices); for device in devices.iter() { pipeline::record_sighting(device.clone(), DeviceEventScanner::Snmp); } diff --git a/backend/src/scanners/snmp/status.rs b/backend/src/scanners/snmp/status.rs index 77e260e..912bb6b 100644 --- a/backend/src/scanners/snmp/status.rs +++ b/backend/src/scanners/snmp/status.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use once_cell::sync::OnceCell; use std::sync::Mutex; +use crate::model::devices::Device; use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus}; static STATUS: OnceCell> = OnceCell::new(); @@ -22,9 +23,9 @@ pub fn set_waiting(next_scan_at: DateTime) { } } -pub fn record_scan(devices_seen: u64) { +pub fn record_scan(devices: &[Device]) { if let Some(m) = STATUS.get() { - m.lock().unwrap().record_scan(devices_seen); + m.lock().unwrap().record_scan(devices); } }