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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-02 08:45:45 -04:00
co-authored by Claude Opus 4.8
parent 38158a4ffc
commit d15411a889
11 changed files with 118 additions and 13 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
- [x] Add configuration options to enable/disable each scanner - [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 - [ ] 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 - [x] Review the recommended timings for the ARP scanner (change defaults) — SNMP defaults set to 10m/5s
## Frontend ## Frontend
+1
View File
@@ -22,6 +22,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
info!("Done with ARP probes"); info!("Done with ARP probes");
info!("Found {} online devices", devices.len()); info!("Found {} online devices", devices.len());
status::record_scan(devices.len() as u64);
// Process found devices // Process found devices
for device in devices.iter() { for device in devices.iter() {
+29
View File
@@ -6,6 +6,8 @@ pub struct ArpScannerStatus {
pub is_running: bool, pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>, pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>, pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -13,6 +15,8 @@ pub struct ArpScannerStatusSnapshot {
pub is_running: bool, pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>, pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>, pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
} }
static STATUS: OnceCell<Mutex<ArpScannerStatus>> = OnceCell::new(); static STATUS: OnceCell<Mutex<ArpScannerStatus>> = OnceCell::new();
@@ -23,6 +27,8 @@ pub fn init() {
is_running: false, is_running: false,
scan_started_at: None, scan_started_at: None,
next_scan_at: None, next_scan_at: None,
last_scan_devices_seen: None,
last_scan_at: None,
})) }))
.ok(); .ok();
} }
@@ -45,6 +51,14 @@ pub fn set_waiting(next_scan_at: DateTime<Utc>) {
} }
} }
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<ArpScannerStatusSnapshot> { pub fn get() -> Option<ArpScannerStatusSnapshot> {
STATUS.get().map(|m| { STATUS.get().map(|m| {
let s = m.lock().unwrap(); let s = m.lock().unwrap();
@@ -52,6 +66,8 @@ pub fn get() -> Option<ArpScannerStatusSnapshot> {
is_running: s.is_running, is_running: s.is_running,
scan_started_at: s.scan_started_at, scan_started_at: s.scan_started_at,
next_scan_at: s.next_scan_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.is_running = false;
s.scan_started_at = None; s.scan_started_at = None;
s.next_scan_at = None; s.next_scan_at = None;
s.last_scan_devices_seen = None;
s.last_scan_at = None;
} else { } else {
init(); init();
} }
@@ -92,6 +110,15 @@ mod tests {
assert_eq!(snapshot.next_scan_at.unwrap(), next); 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] #[test]
fn test_initial_state() { fn test_initial_state() {
reset_for_test(); reset_for_test();
@@ -99,5 +126,7 @@ mod tests {
assert!(!snapshot.is_running); assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none()); assert!(snapshot.scan_started_at.is_none());
assert!(snapshot.next_scan_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());
} }
} }
+1
View File
@@ -33,6 +33,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
match finder::find(config).await { match finder::find(config).await {
Ok(devices) => { Ok(devices) => {
info!("SNMP poll found {} devices in the ARP cache", devices.len()); info!("SNMP poll found {} devices in the ARP cache", devices.len());
status::record_scan(devices.len() as u64);
for device in devices.iter() { for device in devices.iter() {
process_device(device); process_device(device);
} }
+29
View File
@@ -6,6 +6,8 @@ pub struct SnmpScannerStatus {
pub is_running: bool, pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>, pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>, pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -13,6 +15,8 @@ pub struct SnmpScannerStatusSnapshot {
pub is_running: bool, pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>, pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>, pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
} }
static STATUS: OnceCell<Mutex<SnmpScannerStatus>> = OnceCell::new(); static STATUS: OnceCell<Mutex<SnmpScannerStatus>> = OnceCell::new();
@@ -23,6 +27,8 @@ pub fn init() {
is_running: false, is_running: false,
scan_started_at: None, scan_started_at: None,
next_scan_at: None, next_scan_at: None,
last_scan_devices_seen: None,
last_scan_at: None,
})) }))
.ok(); .ok();
} }
@@ -45,6 +51,14 @@ pub fn set_waiting(next_scan_at: DateTime<Utc>) {
} }
} }
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<SnmpScannerStatusSnapshot> { pub fn get() -> Option<SnmpScannerStatusSnapshot> {
STATUS.get().map(|m| { STATUS.get().map(|m| {
let s = m.lock().unwrap(); let s = m.lock().unwrap();
@@ -52,6 +66,8 @@ pub fn get() -> Option<SnmpScannerStatusSnapshot> {
is_running: s.is_running, is_running: s.is_running,
scan_started_at: s.scan_started_at, scan_started_at: s.scan_started_at,
next_scan_at: s.next_scan_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.is_running = false;
s.scan_started_at = None; s.scan_started_at = None;
s.next_scan_at = None; s.next_scan_at = None;
s.last_scan_devices_seen = None;
s.last_scan_at = None;
} else { } else {
init(); init();
} }
@@ -92,6 +110,15 @@ mod tests {
assert_eq!(snapshot.next_scan_at.unwrap(), next); 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] #[test]
fn test_initial_state() { fn test_initial_state() {
reset_for_test(); reset_for_test();
@@ -99,5 +126,7 @@ mod tests {
assert!(!snapshot.is_running); assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none()); assert!(snapshot.scan_started_at.is_none());
assert!(snapshot.next_scan_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());
} }
} }
+10
View File
@@ -11,6 +11,10 @@ pub struct ArpScannerStatusResponse {
pub running_for_seconds: Option<f64>, pub running_for_seconds: Option<f64>,
/// Seconds until the next scan starts (only set when is_running is false; clamped to 0) /// Seconds until the next scan starts (only set when is_running is false; clamped to 0)
pub next_run_in_seconds: Option<f64>, pub next_run_in_seconds: Option<f64>,
/// Number of devices found by the last successful scan (None if none completed yet)
pub last_scan_devices_seen: Option<u64>,
/// Seconds since the last successful scan completed (None if none completed yet)
pub last_scan_seconds_ago: Option<f64>,
} }
#[utoipa::path( #[utoipa::path(
@@ -47,9 +51,15 @@ pub async fn status() -> Result<Json<ArpScannerStatusResponse>, StatusCode> {
(None, next_run_in) (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 { Ok(Json(ArpScannerStatusResponse {
is_running: snapshot.is_running, is_running: snapshot.is_running,
running_for_seconds, running_for_seconds,
next_run_in_seconds, next_run_in_seconds,
last_scan_devices_seen: snapshot.last_scan_devices_seen,
last_scan_seconds_ago,
})) }))
} }
+10
View File
@@ -11,6 +11,10 @@ pub struct SnmpScannerStatusResponse {
pub running_for_seconds: Option<f64>, pub running_for_seconds: Option<f64>,
/// Seconds until the next poll starts (only set when is_running is false; clamped to 0) /// Seconds until the next poll starts (only set when is_running is false; clamped to 0)
pub next_run_in_seconds: Option<f64>, pub next_run_in_seconds: Option<f64>,
/// Number of devices found by the last successful scan (None if none completed yet)
pub last_scan_devices_seen: Option<u64>,
/// Seconds since the last successful scan completed (None if none completed yet)
pub last_scan_seconds_ago: Option<f64>,
} }
#[utoipa::path( #[utoipa::path(
@@ -47,9 +51,15 @@ pub async fn status() -> Result<Json<SnmpScannerStatusResponse>, StatusCode> {
(None, next_run_in) (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 { Ok(Json(SnmpScannerStatusResponse {
is_running: snapshot.is_running, is_running: snapshot.is_running,
running_for_seconds, running_for_seconds,
next_run_in_seconds, next_run_in_seconds,
last_scan_devices_seen: snapshot.last_scan_devices_seen,
last_scan_seconds_ago,
})) }))
} }
@@ -2,11 +2,15 @@ class ArpScannerStatus {
final bool isRunning; final bool isRunning;
final double? runningForSeconds; final double? runningForSeconds;
final double? nextRunInSeconds; final double? nextRunInSeconds;
final int? lastScanDevicesSeen;
final double? lastScanSecondsAgo;
const ArpScannerStatus({ const ArpScannerStatus({
required this.isRunning, required this.isRunning,
this.runningForSeconds, this.runningForSeconds,
this.nextRunInSeconds, this.nextRunInSeconds,
this.lastScanDevicesSeen,
this.lastScanSecondsAgo,
}); });
factory ArpScannerStatus.fromJson(Map<String, dynamic> json) { factory ArpScannerStatus.fromJson(Map<String, dynamic> json) {
@@ -14,6 +18,8 @@ class ArpScannerStatus {
isRunning: json['is_running'] as bool, isRunning: json['is_running'] as bool,
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
nextRunInSeconds: (json['next_run_in_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(),
); );
} }
} }
@@ -2,11 +2,15 @@ class SnmpScannerStatus {
final bool isRunning; final bool isRunning;
final double? runningForSeconds; final double? runningForSeconds;
final double? nextRunInSeconds; final double? nextRunInSeconds;
final int? lastScanDevicesSeen;
final double? lastScanSecondsAgo;
const SnmpScannerStatus({ const SnmpScannerStatus({
required this.isRunning, required this.isRunning,
this.runningForSeconds, this.runningForSeconds,
this.nextRunInSeconds, this.nextRunInSeconds,
this.lastScanDevicesSeen,
this.lastScanSecondsAgo,
}); });
factory SnmpScannerStatus.fromJson(Map<String, dynamic> json) { factory SnmpScannerStatus.fromJson(Map<String, dynamic> json) {
@@ -14,6 +18,8 @@ class SnmpScannerStatus {
isRunning: json['is_running'] as bool, isRunning: json['is_running'] as bool,
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
nextRunInSeconds: (json['next_run_in_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(),
); );
} }
} }
+13 -7
View File
@@ -24,14 +24,20 @@ class ArpScannerCard extends StatelessWidget {
ArpScannerStatus status, ArpScannerStatus status,
double elapsed, double elapsed,
) { ) {
final successColor = final successColor = Theme.of(
Theme.of(context).extension<AppColorExtension>()!.success; context,
).extension<AppColorExtension>()!.success;
final neutralColor = Theme.of(context).colorScheme.outline; final neutralColor = Theme.of(context).colorScheme.outline;
final lastScan = status.lastScanDevicesSeen != null
? '${status.lastScanDevicesSeen} devices on last scan'
: null;
if (status.isRunning) { if (status.isRunning) {
final sub = status.runningForSeconds != null final sublabels = <String>[
? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}'] if (status.runningForSeconds != null)
: <String>[]; 'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
return (color: successColor, label: 'Running', sublabels: sub); ?lastScan,
];
return (color: successColor, label: 'Running', sublabels: sublabels);
} }
if (status.nextRunInSeconds != null) { if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp( final remaining = (status.nextRunInSeconds! - elapsed).clamp(
@@ -41,7 +47,7 @@ class ArpScannerCard extends StatelessWidget {
return ( return (
color: neutralColor, color: neutralColor,
label: 'Waiting for next run', 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: []); return (color: neutralColor, label: 'Not started', sublabels: []);
+10 -5
View File
@@ -28,11 +28,16 @@ class SnmpScannerCard extends StatelessWidget {
context, context,
).extension<AppColorExtension>()!.success; ).extension<AppColorExtension>()!.success;
final neutralColor = Theme.of(context).colorScheme.outline; final neutralColor = Theme.of(context).colorScheme.outline;
final lastScan = status.lastScanDevicesSeen != null
? '${status.lastScanDevicesSeen} devices on last scan'
: null;
if (status.isRunning) { if (status.isRunning) {
final sub = status.runningForSeconds != null final sublabels = <String>[
? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}'] if (status.runningForSeconds != null)
: <String>[]; 'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
return (color: successColor, label: 'Running', sublabels: sub); ?lastScan,
];
return (color: successColor, label: 'Running', sublabels: sublabels);
} }
if (status.nextRunInSeconds != null) { if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp( final remaining = (status.nextRunInSeconds! - elapsed).clamp(
@@ -42,7 +47,7 @@ class SnmpScannerCard extends StatelessWidget {
return ( return (
color: neutralColor, color: neutralColor,
label: 'Waiting for next run', 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: []); return (color: neutralColor, label: 'Not started', sublabels: []);