From d15411a889ca89e845039d071754c7bcad4e86d6 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Tue, 2 Jun 2026 08:45:45 -0400 Subject: [PATCH] Report devices seen on last scan in ARP/SNMP status The ARP and SNMP scanner status endpoints and front-end cards now expose the number of devices found by the most recent successful scan, following the existing mDNS device-count pattern. The count persists across the running/waiting transitions, and for SNMP a failed poll keeps the last good count rather than overwriting it. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 4 ++- backend/src/scanners/arp/scanner.rs | 1 + backend/src/scanners/arp/status.rs | 29 +++++++++++++++++++++ backend/src/scanners/snmp/scanner.rs | 1 + backend/src/scanners/snmp/status.rs | 29 +++++++++++++++++++++ backend/src/web_server/arp_scanner.rs | 10 +++++++ backend/src/web_server/snmp_scanner.rs | 10 +++++++ frontend/lib/model/arp_scanner_status.dart | 6 +++++ frontend/lib/model/snmp_scanner_status.dart | 6 +++++ frontend/lib/widgets/arp_scanner_card.dart | 20 +++++++++----- frontend/lib/widgets/snmp_scanner_card.dart | 15 +++++++---- 11 files changed, 118 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index bd2b883..e808f80 100644 --- a/TODO.md +++ b/TODO.md @@ -9,7 +9,9 @@ - [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 -- [ ] Add "devices seen on last scan" to the ARP and SNMP scanners status +- [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 +- [ ] 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 ## Frontend diff --git a/backend/src/scanners/arp/scanner.rs b/backend/src/scanners/arp/scanner.rs index 02ee780..1c9942c 100644 --- a/backend/src/scanners/arp/scanner.rs +++ b/backend/src/scanners/arp/scanner.rs @@ -22,6 +22,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); // 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 33fae39..52e3723 100644 --- a/backend/src/scanners/arp/status.rs +++ b/backend/src/scanners/arp/status.rs @@ -6,6 +6,8 @@ pub struct ArpScannerStatus { pub is_running: bool, pub scan_started_at: Option>, pub next_scan_at: Option>, + pub last_scan_devices_seen: Option, + pub last_scan_at: Option>, } #[derive(Clone)] @@ -13,6 +15,8 @@ pub struct ArpScannerStatusSnapshot { pub is_running: bool, pub scan_started_at: Option>, pub next_scan_at: Option>, + pub last_scan_devices_seen: Option, + pub last_scan_at: Option>, } static STATUS: OnceCell> = OnceCell::new(); @@ -23,6 +27,8 @@ pub fn init() { is_running: false, scan_started_at: None, next_scan_at: None, + last_scan_devices_seen: None, + last_scan_at: None, })) .ok(); } @@ -45,6 +51,14 @@ pub fn set_waiting(next_scan_at: DateTime) { } } +pub fn record_scan(devices_seen: u64) { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.last_scan_devices_seen = Some(devices_seen); + s.last_scan_at = Some(Utc::now()); + } +} + pub fn get() -> Option { STATUS.get().map(|m| { let s = m.lock().unwrap(); @@ -52,6 +66,8 @@ pub fn get() -> Option { is_running: s.is_running, scan_started_at: s.scan_started_at, next_scan_at: s.next_scan_at, + last_scan_devices_seen: s.last_scan_devices_seen, + last_scan_at: s.last_scan_at, } }) } @@ -66,6 +82,8 @@ mod tests { s.is_running = false; s.scan_started_at = None; s.next_scan_at = None; + s.last_scan_devices_seen = None; + s.last_scan_at = None; } else { init(); } @@ -92,6 +110,15 @@ mod tests { assert_eq!(snapshot.next_scan_at.unwrap(), next); } + #[test] + fn test_record_scan() { + reset_for_test(); + record_scan(7); + let snapshot = get().unwrap(); + assert_eq!(snapshot.last_scan_devices_seen, Some(7)); + assert!(snapshot.last_scan_at.is_some()); + } + #[test] fn test_initial_state() { reset_for_test(); @@ -99,5 +126,7 @@ mod tests { assert!(!snapshot.is_running); assert!(snapshot.scan_started_at.is_none()); assert!(snapshot.next_scan_at.is_none()); + assert!(snapshot.last_scan_devices_seen.is_none()); + assert!(snapshot.last_scan_at.is_none()); } } diff --git a/backend/src/scanners/snmp/scanner.rs b/backend/src/scanners/snmp/scanner.rs index b2a6a65..15bbf4d 100644 --- a/backend/src/scanners/snmp/scanner.rs +++ b/backend/src/scanners/snmp/scanner.rs @@ -33,6 +33,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); for device in devices.iter() { process_device(device); } diff --git a/backend/src/scanners/snmp/status.rs b/backend/src/scanners/snmp/status.rs index 2bbcf26..b8eaa7e 100644 --- a/backend/src/scanners/snmp/status.rs +++ b/backend/src/scanners/snmp/status.rs @@ -6,6 +6,8 @@ pub struct SnmpScannerStatus { pub is_running: bool, pub scan_started_at: Option>, pub next_scan_at: Option>, + pub last_scan_devices_seen: Option, + pub last_scan_at: Option>, } #[derive(Clone)] @@ -13,6 +15,8 @@ pub struct SnmpScannerStatusSnapshot { pub is_running: bool, pub scan_started_at: Option>, pub next_scan_at: Option>, + pub last_scan_devices_seen: Option, + pub last_scan_at: Option>, } static STATUS: OnceCell> = OnceCell::new(); @@ -23,6 +27,8 @@ pub fn init() { is_running: false, scan_started_at: None, next_scan_at: None, + last_scan_devices_seen: None, + last_scan_at: None, })) .ok(); } @@ -45,6 +51,14 @@ pub fn set_waiting(next_scan_at: DateTime) { } } +pub fn record_scan(devices_seen: u64) { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.last_scan_devices_seen = Some(devices_seen); + s.last_scan_at = Some(Utc::now()); + } +} + pub fn get() -> Option { STATUS.get().map(|m| { let s = m.lock().unwrap(); @@ -52,6 +66,8 @@ pub fn get() -> Option { is_running: s.is_running, scan_started_at: s.scan_started_at, next_scan_at: s.next_scan_at, + last_scan_devices_seen: s.last_scan_devices_seen, + last_scan_at: s.last_scan_at, } }) } @@ -66,6 +82,8 @@ mod tests { s.is_running = false; s.scan_started_at = None; s.next_scan_at = None; + s.last_scan_devices_seen = None; + s.last_scan_at = None; } else { init(); } @@ -92,6 +110,15 @@ mod tests { assert_eq!(snapshot.next_scan_at.unwrap(), next); } + #[test] + fn test_record_scan() { + reset_for_test(); + record_scan(7); + let snapshot = get().unwrap(); + assert_eq!(snapshot.last_scan_devices_seen, Some(7)); + assert!(snapshot.last_scan_at.is_some()); + } + #[test] fn test_initial_state() { reset_for_test(); @@ -99,5 +126,7 @@ mod tests { assert!(!snapshot.is_running); assert!(snapshot.scan_started_at.is_none()); assert!(snapshot.next_scan_at.is_none()); + assert!(snapshot.last_scan_devices_seen.is_none()); + assert!(snapshot.last_scan_at.is_none()); } } diff --git a/backend/src/web_server/arp_scanner.rs b/backend/src/web_server/arp_scanner.rs index 2fae12f..ea5d956 100644 --- a/backend/src/web_server/arp_scanner.rs +++ b/backend/src/web_server/arp_scanner.rs @@ -11,6 +11,10 @@ pub struct ArpScannerStatusResponse { pub running_for_seconds: Option, /// Seconds until the next scan starts (only set when is_running is false; clamped to 0) pub next_run_in_seconds: Option, + /// Number of devices found by the last successful scan (None if none completed yet) + pub last_scan_devices_seen: Option, + /// Seconds since the last successful scan completed (None if none completed yet) + pub last_scan_seconds_ago: Option, } #[utoipa::path( @@ -47,9 +51,15 @@ pub async fn status() -> Result, StatusCode> { (None, next_run_in) }; + let last_scan_seconds_ago = snapshot + .last_scan_at + .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); + Ok(Json(ArpScannerStatusResponse { is_running: snapshot.is_running, running_for_seconds, next_run_in_seconds, + last_scan_devices_seen: snapshot.last_scan_devices_seen, + last_scan_seconds_ago, })) } diff --git a/backend/src/web_server/snmp_scanner.rs b/backend/src/web_server/snmp_scanner.rs index 91ee20c..45c51ec 100644 --- a/backend/src/web_server/snmp_scanner.rs +++ b/backend/src/web_server/snmp_scanner.rs @@ -11,6 +11,10 @@ pub struct SnmpScannerStatusResponse { pub running_for_seconds: Option, /// Seconds until the next poll starts (only set when is_running is false; clamped to 0) pub next_run_in_seconds: Option, + /// Number of devices found by the last successful scan (None if none completed yet) + pub last_scan_devices_seen: Option, + /// Seconds since the last successful scan completed (None if none completed yet) + pub last_scan_seconds_ago: Option, } #[utoipa::path( @@ -47,9 +51,15 @@ pub async fn status() -> Result, StatusCode> { (None, next_run_in) }; + let last_scan_seconds_ago = snapshot + .last_scan_at + .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); + Ok(Json(SnmpScannerStatusResponse { is_running: snapshot.is_running, running_for_seconds, next_run_in_seconds, + last_scan_devices_seen: snapshot.last_scan_devices_seen, + last_scan_seconds_ago, })) } diff --git a/frontend/lib/model/arp_scanner_status.dart b/frontend/lib/model/arp_scanner_status.dart index fcddc88..c2c3d91 100644 --- a/frontend/lib/model/arp_scanner_status.dart +++ b/frontend/lib/model/arp_scanner_status.dart @@ -2,11 +2,15 @@ class ArpScannerStatus { final bool isRunning; final double? runningForSeconds; final double? nextRunInSeconds; + final int? lastScanDevicesSeen; + final double? lastScanSecondsAgo; const ArpScannerStatus({ required this.isRunning, this.runningForSeconds, this.nextRunInSeconds, + this.lastScanDevicesSeen, + this.lastScanSecondsAgo, }); factory ArpScannerStatus.fromJson(Map json) { @@ -14,6 +18,8 @@ class ArpScannerStatus { isRunning: json['is_running'] as bool, runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(), + lastScanDevicesSeen: (json['last_scan_devices_seen'] as num?)?.toInt(), + lastScanSecondsAgo: (json['last_scan_seconds_ago'] as num?)?.toDouble(), ); } } diff --git a/frontend/lib/model/snmp_scanner_status.dart b/frontend/lib/model/snmp_scanner_status.dart index 5e682e2..d3fde22 100644 --- a/frontend/lib/model/snmp_scanner_status.dart +++ b/frontend/lib/model/snmp_scanner_status.dart @@ -2,11 +2,15 @@ class SnmpScannerStatus { final bool isRunning; final double? runningForSeconds; final double? nextRunInSeconds; + final int? lastScanDevicesSeen; + final double? lastScanSecondsAgo; const SnmpScannerStatus({ required this.isRunning, this.runningForSeconds, this.nextRunInSeconds, + this.lastScanDevicesSeen, + this.lastScanSecondsAgo, }); factory SnmpScannerStatus.fromJson(Map json) { @@ -14,6 +18,8 @@ class SnmpScannerStatus { isRunning: json['is_running'] as bool, runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(), + lastScanDevicesSeen: (json['last_scan_devices_seen'] as num?)?.toInt(), + lastScanSecondsAgo: (json['last_scan_seconds_ago'] as num?)?.toDouble(), ); } } diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart index 07797a4..526bc0f 100644 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -24,14 +24,20 @@ class ArpScannerCard extends StatelessWidget { ArpScannerStatus status, double elapsed, ) { - final successColor = - Theme.of(context).extension()!.success; + final successColor = Theme.of( + context, + ).extension()!.success; final neutralColor = Theme.of(context).colorScheme.outline; + final lastScan = status.lastScanDevicesSeen != null + ? '${status.lastScanDevicesSeen} devices on last scan' + : null; if (status.isRunning) { - final sub = status.runningForSeconds != null - ? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}'] - : []; - return (color: successColor, label: 'Running', sublabels: sub); + final sublabels = [ + if (status.runningForSeconds != null) + 'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}', + ?lastScan, + ]; + return (color: successColor, label: 'Running', sublabels: sublabels); } if (status.nextRunInSeconds != null) { final remaining = (status.nextRunInSeconds! - elapsed).clamp( @@ -41,7 +47,7 @@ class ArpScannerCard extends StatelessWidget { return ( color: neutralColor, label: 'Waiting for next run', - sublabels: ['Next run in ${formatSeconds(remaining)}'], + sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan], ); } return (color: neutralColor, label: 'Not started', sublabels: []); diff --git a/frontend/lib/widgets/snmp_scanner_card.dart b/frontend/lib/widgets/snmp_scanner_card.dart index 547031b..d8f39c8 100644 --- a/frontend/lib/widgets/snmp_scanner_card.dart +++ b/frontend/lib/widgets/snmp_scanner_card.dart @@ -28,11 +28,16 @@ class SnmpScannerCard extends StatelessWidget { context, ).extension()!.success; final neutralColor = Theme.of(context).colorScheme.outline; + final lastScan = status.lastScanDevicesSeen != null + ? '${status.lastScanDevicesSeen} devices on last scan' + : null; if (status.isRunning) { - final sub = status.runningForSeconds != null - ? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}'] - : []; - return (color: successColor, label: 'Running', sublabels: sub); + final sublabels = [ + if (status.runningForSeconds != null) + 'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}', + ?lastScan, + ]; + return (color: successColor, label: 'Running', sublabels: sublabels); } if (status.nextRunInSeconds != null) { final remaining = (status.nextRunInSeconds! - elapsed).clamp( @@ -42,7 +47,7 @@ class SnmpScannerCard extends StatelessWidget { return ( color: neutralColor, label: 'Waiting for next run', - sublabels: ['Next run in ${formatSeconds(remaining)}'], + sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan], ); } return (color: neutralColor, label: 'Not started', sublabels: []);