Files
oott/frontend/lib/widgets/scanners_status_card.dart
T
rzuastiandClaude Opus 4.8 2987f66363 Add SNMP scanner that polls a gateway's ARP table
Introduce a sixth scanner that periodically queries an SNMP agent
(typically the router/firewall) for its ARP/neighbour cache via
SNMPv2c and feeds discovered devices into the shared devices, events
and notifications pipeline. Unlike the ARP scanner it generates no
traffic on the local segment and can surface devices across all
subnets the agent routes.

Scope is intentionally minimal: SNMPv2c only, a single target, and the
ipNetToMediaTable (ARP) only. SNMPv3 and switch MAC/forwarding-table
polling are left as follow-ups in TODO.md.

- backend: csnmp dependency; SnmpScanner config (opt-in, off unless a
  [snmp_scanner] section is present); DeviceEventScanner::Snmp;
  scanners/snmp/{finder,scanner,status}; main.rs wiring; status API
  endpoint wired into OpenAPI
- frontend: SNMP scanner status model, card, API method, status screen
  and summary card rows, and device-event label
- docs/config: sample TOML, README options, NixOS module option, and
  setup notes for enabling SNMP on pfSense/OPNsense

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:03:20 -04:00

407 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/arp_scanner_status.dart';
import '../model/dhcp_scanner_status.dart';
import '../model/mdns_scanner_status.dart';
import '../model/snmp_scanner_status.dart';
import '../model/ssdp_scanner_status.dart';
import '../navigation.dart';
import '../theme/app_colors.dart';
import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart';
class ScannersStatusCard extends StatefulWidget {
const ScannersStatusCard({super.key});
@override
State<ScannersStatusCard> createState() => _ScannersStatusCardState();
}
class _ScannersStatusCardState extends State<ScannersStatusCard>
with RouteAware, WidgetsBindingObserver {
late final PolledValue<ArpScannerStatus> _arp;
late final PolledValue<MdnsScannerStatus> _mdns;
late final PolledValue<SsdpScannerStatus> _ssdp;
late final PolledValue<DhcpScannerStatus> _dhcp;
late final PolledValue<SnmpScannerStatus> _snmp;
Timer? _tickTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_arp = PolledValue<ArpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_mdns = PolledValue<MdnsScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_ssdp = PolledValue<SsdpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_dhcp = PolledValue<DhcpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_snmp = PolledValue<SnmpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is ModalRoute<void>) {
routeObserver.subscribe(this, route);
}
}
@override
void didPushNext() {
_pausePolling();
}
@override
void didPopNext() {
_resumePolling();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_pausePolling();
} else if (state == AppLifecycleState.resumed) {
_resumePolling();
}
}
void _pausePolling() {
_arp.pause();
_mdns.pause();
_ssdp.pause();
_dhcp.pause();
_snmp.pause();
_tickTimer?.cancel();
_tickTimer = null;
}
void _resumePolling() {
_arp.resume();
_mdns.resume();
_ssdp.resume();
_dhcp.resume();
_snmp.resume();
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_tickTimer?.cancel();
_arp.dispose();
_mdns.dispose();
_ssdp.dispose();
_dhcp.dispose();
_snmp.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([
_arp,
_mdns,
_ssdp,
_dhcp,
_snmp,
BackendReachability.instance,
]),
builder: (context, _) {
final arpFreshness = effectiveFreshness(_arp);
final mdnsFreshness = effectiveFreshness(_mdns);
final ssdpFreshness = effectiveFreshness(_ssdp);
final dhcpFreshness = effectiveFreshness(_dhcp);
final snmpFreshness = effectiveFreshness(_snmp);
if (arpFreshness == PolledFreshness.initialLoading ||
mdnsFreshness == PolledFreshness.initialLoading ||
ssdpFreshness == PolledFreshness.initialLoading ||
dhcpFreshness == PolledFreshness.initialLoading ||
snmpFreshness == PolledFreshness.initialLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
);
}
final (arpColor, arpText) = _resolveArp(context, arpFreshness);
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
final (dhcpColor, dhcpText) = _resolveDhcp(context, dhcpFreshness);
final (snmpColor, snmpText) = _resolveSnmp(context, snmpFreshness);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/status'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_scannerRow(
context,
arpColor,
'ARP',
arpText,
_arp,
arpFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
mdnsColor,
'mDNS',
mdnsText,
_mdns,
mdnsFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
ssdpColor,
'SSDP/UPnP',
ssdpText,
_ssdp,
ssdpFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
dhcpColor,
'DHCP',
dhcpText,
_dhcp,
dhcpFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
snmpColor,
'SNMP',
snmpText,
_snmp,
snmpFreshness,
),
],
),
),
),
);
},
);
}
Widget _scannerRow(
BuildContext context,
Color color,
String name,
String statusText,
PolledValue polled,
PolledFreshness freshness,
) {
Widget dot = Icon(Icons.circle, color: color, size: 12);
if (freshness == PolledFreshness.error) {
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot);
}
return Row(
children: [
dot,
const SizedBox(width: 10),
Expanded(
child: Row(
children: [
Text(name, style: Theme.of(context).textTheme.bodyMedium),
if (freshness == PolledFreshness.stale) ...[
const SizedBox(width: 6),
PolledStaleIndicator(polled: polled),
],
],
),
),
const SizedBox(width: 8),
Text(
statusText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
);
}
(Color, String) _resolveArp(BuildContext context, PolledFreshness freshness) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _arp.value!;
final elapsed = DateTime.now()
.difference(_arp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isRunning) {
final secs = (status.runningForSeconds ?? 0) + elapsed;
return (successColor, 'Running for ${formatSeconds(secs)}');
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (neutralColor, 'Next run in ${formatSeconds(remaining)}');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveMdns(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _mdns.value!;
final elapsed = DateTime.now()
.difference(_mdns.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveSsdp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _ssdp.value!;
final elapsed = DateTime.now()
.difference(_ssdp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveDhcp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _dhcp.value!;
final elapsed = DateTime.now()
.difference(_dhcp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveSnmp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _snmp.value!;
final elapsed = DateTime.now()
.difference(_snmp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isRunning) {
final secs = (status.runningForSeconds ?? 0) + elapsed;
return (successColor, 'Running for ${formatSeconds(secs)}');
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (neutralColor, 'Next run in ${formatSeconds(remaining)}');
}
return (neutralColor, 'Not started');
}
}