diff --git a/TODO.md b/TODO.md index e808f80..4ea2f75 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,8 @@ - [x] Add configuration options to enable/disable each scanner - [ ] Implement the pushover API call directly to support HTML content and review notification text to use it - [x] Add "devices seen on last scan" to the ARP and SNMP scanners status -- [ ] Modify the passive scanners status to count devices seen in the last hour +- [x] Modify the passive scanners status to count devices seen in the last hour +- [ ] Simplify the code of scanners (repeated logic) - [ ] Modify the event management to ignore duplicate events if they happen within a time threshold - [x] Review the recommended timings for the ARP scanner (change defaults) — SNMP defaults set to 10m/5s diff --git a/backend/src/scanners/dhcp/scanner.rs b/backend/src/scanners/dhcp/scanner.rs index acd090d..7cb57f2 100644 --- a/backend/src/scanners/dhcp/scanner.rs +++ b/backend/src/scanners/dhcp/scanner.rs @@ -99,5 +99,5 @@ async fn process_discovery(discovery: finder::DhcpDiscovery) { } } - status::record_discovery(); + status::record_discovery(&mac); } diff --git a/backend/src/scanners/dhcp/status.rs b/backend/src/scanners/dhcp/status.rs index 4d694b0..f8f498e 100644 --- a/backend/src/scanners/dhcp/status.rs +++ b/backend/src/scanners/dhcp/status.rs @@ -1,11 +1,18 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; +use std::collections::HashMap; use std::sync::Mutex; +/// A device counts as "seen" only if its most recent sighting falls within this +/// rolling window. +const RECENT_WINDOW_SECONDS: i64 = 3600; + pub struct DhcpScannerStatus { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Most recent sighting time per device MAC, used to count the distinct + /// devices seen within the last hour. + pub recent_sightings: HashMap>, pub last_discovery_at: Option>, } @@ -13,7 +20,8 @@ pub struct DhcpScannerStatus { pub struct DhcpScannerStatusSnapshot { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Distinct devices seen within the last hour. + pub devices_last_hour: u64, pub last_discovery_at: Option>, } @@ -24,7 +32,7 @@ pub fn init() { .set(Mutex::new(DhcpScannerStatus { is_listening: false, listening_since: None, - devices_discovered: 0, + recent_sightings: HashMap::new(), last_discovery_at: None, })) .ok(); @@ -38,21 +46,24 @@ pub fn set_listening() { } } -pub fn record_discovery() { +pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { + let now = Utc::now(); let mut s = m.lock().unwrap(); - s.devices_discovered += 1; - s.last_discovery_at = Some(Utc::now()); + s.recent_sightings.insert(mac.to_string(), now); + s.last_discovery_at = Some(now); } } pub fn get() -> Option { STATUS.get().map(|m| { - let s = m.lock().unwrap(); + let mut s = m.lock().unwrap(); + let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); + s.recent_sightings.retain(|_, seen| *seen >= cutoff); DhcpScannerStatusSnapshot { is_listening: s.is_listening, listening_since: s.listening_since, - devices_discovered: s.devices_discovered, + devices_last_hour: s.recent_sightings.len() as u64, last_discovery_at: s.last_discovery_at, } }) @@ -67,7 +78,7 @@ mod tests { let mut s = m.lock().unwrap(); s.is_listening = false; s.listening_since = None; - s.devices_discovered = 0; + s.recent_sightings.clear(); s.last_discovery_at = None; } else { init(); @@ -84,22 +95,47 @@ mod tests { } #[test] - fn test_record_discovery() { + fn test_same_mac_counts_once() { reset_for_test(); - record_discovery(); - record_discovery(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("aa:bb:cc:dd:ee:ff"); let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_discovered, 2); + assert_eq!(snapshot.devices_last_hour, 1); assert!(snapshot.last_discovery_at.is_some()); } + #[test] + fn test_distinct_macs_counted() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("11:22:33:44:55:66"); + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 2); + } + + #[test] + fn test_old_sighting_excluded() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + // Backdate an entry beyond the window; it must not be counted. + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.recent_sightings.insert( + "11:22:33:44:55:66".to_string(), + Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), + ); + } + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 1); + } + #[test] fn test_initial_state() { reset_for_test(); let snapshot = get().unwrap(); assert!(!snapshot.is_listening); assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_discovered, 0); + assert_eq!(snapshot.devices_last_hour, 0); assert!(snapshot.last_discovery_at.is_none()); } } diff --git a/backend/src/scanners/mdns/scanner.rs b/backend/src/scanners/mdns/scanner.rs index ca0a936..f049f06 100644 --- a/backend/src/scanners/mdns/scanner.rs +++ b/backend/src/scanners/mdns/scanner.rs @@ -123,5 +123,5 @@ async fn process_announcement( } } - status::record_discovery(); + status::record_discovery(&mac); } diff --git a/backend/src/scanners/mdns/status.rs b/backend/src/scanners/mdns/status.rs index 393ee21..2cd0e23 100644 --- a/backend/src/scanners/mdns/status.rs +++ b/backend/src/scanners/mdns/status.rs @@ -1,11 +1,18 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; +use std::collections::HashMap; use std::sync::Mutex; +/// A device counts as "seen" only if its most recent sighting falls within this +/// rolling window. +const RECENT_WINDOW_SECONDS: i64 = 3600; + pub struct MdnsScannerStatus { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Most recent sighting time per device MAC, used to count the distinct + /// devices seen within the last hour. + pub recent_sightings: HashMap>, pub last_discovery_at: Option>, } @@ -13,7 +20,8 @@ pub struct MdnsScannerStatus { pub struct MdnsScannerStatusSnapshot { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Distinct devices seen within the last hour. + pub devices_last_hour: u64, pub last_discovery_at: Option>, } @@ -24,7 +32,7 @@ pub fn init() { .set(Mutex::new(MdnsScannerStatus { is_listening: false, listening_since: None, - devices_discovered: 0, + recent_sightings: HashMap::new(), last_discovery_at: None, })) .ok(); @@ -38,21 +46,24 @@ pub fn set_listening() { } } -pub fn record_discovery() { +pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { + let now = Utc::now(); let mut s = m.lock().unwrap(); - s.devices_discovered += 1; - s.last_discovery_at = Some(Utc::now()); + s.recent_sightings.insert(mac.to_string(), now); + s.last_discovery_at = Some(now); } } pub fn get() -> Option { STATUS.get().map(|m| { - let s = m.lock().unwrap(); + let mut s = m.lock().unwrap(); + let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); + s.recent_sightings.retain(|_, seen| *seen >= cutoff); MdnsScannerStatusSnapshot { is_listening: s.is_listening, listening_since: s.listening_since, - devices_discovered: s.devices_discovered, + devices_last_hour: s.recent_sightings.len() as u64, last_discovery_at: s.last_discovery_at, } }) @@ -67,7 +78,7 @@ mod tests { let mut s = m.lock().unwrap(); s.is_listening = false; s.listening_since = None; - s.devices_discovered = 0; + s.recent_sightings.clear(); s.last_discovery_at = None; } else { init(); @@ -84,22 +95,47 @@ mod tests { } #[test] - fn test_record_discovery() { + fn test_same_mac_counts_once() { reset_for_test(); - record_discovery(); - record_discovery(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("aa:bb:cc:dd:ee:ff"); let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_discovered, 2); + assert_eq!(snapshot.devices_last_hour, 1); assert!(snapshot.last_discovery_at.is_some()); } + #[test] + fn test_distinct_macs_counted() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("11:22:33:44:55:66"); + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 2); + } + + #[test] + fn test_old_sighting_excluded() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + // Backdate an entry beyond the window; it must not be counted. + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.recent_sightings.insert( + "11:22:33:44:55:66".to_string(), + Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), + ); + } + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 1); + } + #[test] fn test_initial_state() { reset_for_test(); let snapshot = get().unwrap(); assert!(!snapshot.is_listening); assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_discovered, 0); + assert_eq!(snapshot.devices_last_hour, 0); assert!(snapshot.last_discovery_at.is_none()); } } diff --git a/backend/src/scanners/ssdp/scanner.rs b/backend/src/scanners/ssdp/scanner.rs index 3ecc235..9cf8c3f 100644 --- a/backend/src/scanners/ssdp/scanner.rs +++ b/backend/src/scanners/ssdp/scanner.rs @@ -123,5 +123,5 @@ async fn process_announcement( } } - status::record_discovery(); + status::record_discovery(&mac); } diff --git a/backend/src/scanners/ssdp/status.rs b/backend/src/scanners/ssdp/status.rs index acbf902..ca10fb5 100644 --- a/backend/src/scanners/ssdp/status.rs +++ b/backend/src/scanners/ssdp/status.rs @@ -1,11 +1,18 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; +use std::collections::HashMap; use std::sync::Mutex; +/// A device counts as "seen" only if its most recent sighting falls within this +/// rolling window. +const RECENT_WINDOW_SECONDS: i64 = 3600; + pub struct SsdpScannerStatus { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Most recent sighting time per device MAC, used to count the distinct + /// devices seen within the last hour. + pub recent_sightings: HashMap>, pub last_discovery_at: Option>, } @@ -13,7 +20,8 @@ pub struct SsdpScannerStatus { pub struct SsdpScannerStatusSnapshot { pub is_listening: bool, pub listening_since: Option>, - pub devices_discovered: u64, + /// Distinct devices seen within the last hour. + pub devices_last_hour: u64, pub last_discovery_at: Option>, } @@ -24,7 +32,7 @@ pub fn init() { .set(Mutex::new(SsdpScannerStatus { is_listening: false, listening_since: None, - devices_discovered: 0, + recent_sightings: HashMap::new(), last_discovery_at: None, })) .ok(); @@ -38,21 +46,24 @@ pub fn set_listening() { } } -pub fn record_discovery() { +pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { + let now = Utc::now(); let mut s = m.lock().unwrap(); - s.devices_discovered += 1; - s.last_discovery_at = Some(Utc::now()); + s.recent_sightings.insert(mac.to_string(), now); + s.last_discovery_at = Some(now); } } pub fn get() -> Option { STATUS.get().map(|m| { - let s = m.lock().unwrap(); + let mut s = m.lock().unwrap(); + let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); + s.recent_sightings.retain(|_, seen| *seen >= cutoff); SsdpScannerStatusSnapshot { is_listening: s.is_listening, listening_since: s.listening_since, - devices_discovered: s.devices_discovered, + devices_last_hour: s.recent_sightings.len() as u64, last_discovery_at: s.last_discovery_at, } }) @@ -67,7 +78,7 @@ mod tests { let mut s = m.lock().unwrap(); s.is_listening = false; s.listening_since = None; - s.devices_discovered = 0; + s.recent_sightings.clear(); s.last_discovery_at = None; } else { init(); @@ -84,22 +95,47 @@ mod tests { } #[test] - fn test_record_discovery() { + fn test_same_mac_counts_once() { reset_for_test(); - record_discovery(); - record_discovery(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("aa:bb:cc:dd:ee:ff"); let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_discovered, 2); + assert_eq!(snapshot.devices_last_hour, 1); assert!(snapshot.last_discovery_at.is_some()); } + #[test] + fn test_distinct_macs_counted() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + record_discovery("11:22:33:44:55:66"); + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 2); + } + + #[test] + fn test_old_sighting_excluded() { + reset_for_test(); + record_discovery("aa:bb:cc:dd:ee:ff"); + // Backdate an entry beyond the window; it must not be counted. + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.recent_sightings.insert( + "11:22:33:44:55:66".to_string(), + Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), + ); + } + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_last_hour, 1); + } + #[test] fn test_initial_state() { reset_for_test(); let snapshot = get().unwrap(); assert!(!snapshot.is_listening); assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_discovered, 0); + assert_eq!(snapshot.devices_last_hour, 0); assert!(snapshot.last_discovery_at.is_none()); } } diff --git a/backend/src/web_server/dhcp_scanner.rs b/backend/src/web_server/dhcp_scanner.rs index 9b28263..ccb9426 100644 --- a/backend/src/web_server/dhcp_scanner.rs +++ b/backend/src/web_server/dhcp_scanner.rs @@ -9,7 +9,7 @@ pub struct DhcpScannerStatusResponse { pub is_listening: bool, /// Seconds the listener has been running (only set when is_listening is true) pub listening_for_seconds: Option, - /// Total device requests processed since the listener started + /// Distinct devices seen in the last hour pub devices_seen: u64, /// Seconds since the last device was seen (None if none seen yet) pub last_device_seen_seconds_ago: Option, @@ -51,7 +51,7 @@ pub async fn status() -> Result, StatusCode> { Ok(Json(DhcpScannerStatusResponse { is_listening: snapshot.is_listening, listening_for_seconds, - devices_seen: snapshot.devices_discovered, + devices_seen: snapshot.devices_last_hour, last_device_seen_seconds_ago, })) } diff --git a/backend/src/web_server/mdns_scanner.rs b/backend/src/web_server/mdns_scanner.rs index fc78e38..0de549d 100644 --- a/backend/src/web_server/mdns_scanner.rs +++ b/backend/src/web_server/mdns_scanner.rs @@ -9,7 +9,7 @@ pub struct MdnsScannerStatusResponse { pub is_listening: bool, /// Seconds the listener has been running (only set when is_listening is true) pub listening_for_seconds: Option, - /// Total device announcements processed since the listener started + /// Distinct devices seen in the last hour pub devices_seen: u64, /// Seconds since the last device was seen (None if none seen yet) pub last_device_seen_seconds_ago: Option, @@ -51,7 +51,7 @@ pub async fn status() -> Result, StatusCode> { Ok(Json(MdnsScannerStatusResponse { is_listening: snapshot.is_listening, listening_for_seconds, - devices_seen: snapshot.devices_discovered, + devices_seen: snapshot.devices_last_hour, last_device_seen_seconds_ago, })) } diff --git a/backend/src/web_server/ssdp_scanner.rs b/backend/src/web_server/ssdp_scanner.rs index 23b1279..8eafd95 100644 --- a/backend/src/web_server/ssdp_scanner.rs +++ b/backend/src/web_server/ssdp_scanner.rs @@ -9,7 +9,7 @@ pub struct SsdpScannerStatusResponse { pub is_listening: bool, /// Seconds the listener has been running (only set when is_listening is true) pub listening_for_seconds: Option, - /// Total device announcements processed since the listener started + /// Distinct devices seen in the last hour pub devices_seen: u64, /// Seconds since the last device was seen (None if none seen yet) pub last_device_seen_seconds_ago: Option, @@ -51,7 +51,7 @@ pub async fn status() -> Result, StatusCode> { Ok(Json(SsdpScannerStatusResponse { is_listening: snapshot.is_listening, listening_for_seconds, - devices_seen: snapshot.devices_discovered, + devices_seen: snapshot.devices_last_hour, last_device_seen_seconds_ago, })) } diff --git a/frontend/lib/widgets/dhcp_scanner_card.dart b/frontend/lib/widgets/dhcp_scanner_card.dart index bd8c6eb..78fca78 100644 --- a/frontend/lib/widgets/dhcp_scanner_card.dart +++ b/frontend/lib/widgets/dhcp_scanner_card.dart @@ -33,8 +33,8 @@ class DhcpScannerCard extends StatelessWidget { } final sublabels = [ status.listeningForSeconds != null - ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen' - : '${status.devicesSeen} devices seen', + ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour' + : '${status.devicesSeen} devices in the last hour', if (status.lastDeviceSeenSecondsAgo != null) 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', ]; diff --git a/frontend/lib/widgets/mdns_scanner_card.dart b/frontend/lib/widgets/mdns_scanner_card.dart index 024b8d6..badce7a 100644 --- a/frontend/lib/widgets/mdns_scanner_card.dart +++ b/frontend/lib/widgets/mdns_scanner_card.dart @@ -33,8 +33,8 @@ class MdnsScannerCard extends StatelessWidget { } final sublabels = [ status.listeningForSeconds != null - ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen' - : '${status.devicesSeen} devices seen', + ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour' + : '${status.devicesSeen} devices in the last hour', if (status.lastDeviceSeenSecondsAgo != null) 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', ]; diff --git a/frontend/lib/widgets/ssdp_scanner_card.dart b/frontend/lib/widgets/ssdp_scanner_card.dart index 0321eba..d6c5306 100644 --- a/frontend/lib/widgets/ssdp_scanner_card.dart +++ b/frontend/lib/widgets/ssdp_scanner_card.dart @@ -33,8 +33,8 @@ class SsdpScannerCard extends StatelessWidget { } final sublabels = [ status.listeningForSeconds != null - ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen' - : '${status.devicesSeen} devices seen', + ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour' + : '${status.devicesSeen} devices in the last hour', if (status.lastDeviceSeenSecondsAgo != null) 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', ];