From 75f597bfe4ca5dacc8c88d954ffefe3a559a87a4 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Fri, 29 May 2026 10:19:50 -0400 Subject: [PATCH] Add mDNS scanner status to Status and Home screens Add an mDNS Scanner status card to the Status screen mirroring the ARP card, and replace the ARP card on the Home screen with a combined Status card that summarizes both scanners in one line each. Extract the shared duration formatting helper into utils/duration_formatter.dart. Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 6 +- frontend/lib/home/home_screen.dart | 6 +- frontend/lib/model/mdns_scanner_status.dart | 23 +++ frontend/lib/status/status_screen.dart | 3 + frontend/lib/utils/duration_formatter.dart | 7 + frontend/lib/utils/oott_api.dart | 13 +- frontend/lib/widgets/arp_scanner_card.dart | 27 +-- frontend/lib/widgets/mdns_scanner_card.dart | 147 +++++++++++++++ .../lib/widgets/scanners_status_card.dart | 167 ++++++++++++++++++ 9 files changed, 372 insertions(+), 27 deletions(-) create mode 100644 frontend/lib/model/mdns_scanner_status.dart create mode 100644 frontend/lib/utils/duration_formatter.dart create mode 100644 frontend/lib/widgets/mdns_scanner_card.dart create mode 100644 frontend/lib/widgets/scanners_status_card.dart diff --git a/TODO.md b/TODO.md index e1369d9..21d6eba 100644 --- a/TODO.md +++ b/TODO.md @@ -17,8 +17,10 @@ ## Frontend -- [ ] Add pagination to the devices list screen -- [ ] Add the mDNS/Bonjour scanner status to the Status and Home screens +- [ ] Add a "go to first" and "go to last" buttons to paginations (notification list for now) +- [x] Replace the ARP scanner widget in the home screen with a Scanning status widget that provides a one liner for each scanner +- [ ] Add pagination to the devices list screen - now you can't see all devices +- [x] Add the mDNS/Bonjour scanner status to the Status and Home screens - [x] Change the notifications list so it has explicit paging (not infinite paging) - [x] List recorded devices - [x] Register a device diff --git a/frontend/lib/home/home_screen.dart b/frontend/lib/home/home_screen.dart index 8bab363..4fb5cc3 100644 --- a/frontend/lib/home/home_screen.dart +++ b/frontend/lib/home/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../widgets/arp_scanner_card.dart'; import '../widgets/device_summary_card.dart'; +import '../widgets/scanners_status_card.dart'; import 'notifications_list.dart'; const _twoColumnBreakpoint = 700.0; @@ -33,7 +33,7 @@ class HomeScreen extends StatelessWidget { children: [ DeviceSummaryCard(), SizedBox(height: 16), - ArpScannerCard(), + ScannersStatusCard(), ], ), ), @@ -54,7 +54,7 @@ class HomeScreen extends StatelessWidget { SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only(top: 16, bottom: 20), - child: ArpScannerCard(), + child: ScannersStatusCard(), ), ), ], diff --git a/frontend/lib/model/mdns_scanner_status.dart b/frontend/lib/model/mdns_scanner_status.dart new file mode 100644 index 0000000..0dfb5e1 --- /dev/null +++ b/frontend/lib/model/mdns_scanner_status.dart @@ -0,0 +1,23 @@ +class MdnsScannerStatus { + final bool isListening; + final double? listeningForSeconds; + final int devicesSeen; + final double? lastDeviceSeenSecondsAgo; + + const MdnsScannerStatus({ + required this.isListening, + this.listeningForSeconds, + required this.devicesSeen, + this.lastDeviceSeenSecondsAgo, + }); + + factory MdnsScannerStatus.fromJson(Map json) { + return MdnsScannerStatus( + isListening: json['is_listening'] as bool, + listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(), + devicesSeen: (json['devices_seen'] as num).toInt(), + lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?) + ?.toDouble(), + ); + } +} diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart index ef70b96..dd717f3 100644 --- a/frontend/lib/status/status_screen.dart +++ b/frontend/lib/status/status_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../widgets/arp_scanner_card.dart'; +import '../widgets/mdns_scanner_card.dart'; class StatusScreen extends StatelessWidget { const StatusScreen({super.key}); @@ -13,6 +14,8 @@ class StatusScreen extends StatelessWidget { Text('Status', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 20), const ArpScannerCard(), + const SizedBox(height: 8), + const MdnsScannerCard(), ], ); } diff --git a/frontend/lib/utils/duration_formatter.dart b/frontend/lib/utils/duration_formatter.dart new file mode 100644 index 0000000..15b5c85 --- /dev/null +++ b/frontend/lib/utils/duration_formatter.dart @@ -0,0 +1,7 @@ +String formatSeconds(double seconds) { + final total = seconds.round().clamp(0, double.maxFinite.toInt()); + if (total < 60) return '${total}s'; + final m = total ~/ 60; + final s = total % 60; + return '${m}m ${s}s'; +} diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 9153f49..feaf807 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -5,6 +5,7 @@ import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/foundation.dart'; import 'package:frontend/utils/pref_utils.dart'; import '../model/arp_scanner_status.dart'; +import '../model/mdns_scanner_status.dart'; import '../model/device.dart'; import '../model/device_event.dart'; import '../model/device_summary.dart'; @@ -105,8 +106,9 @@ class BackendAPI { if (isRegistered != null) params['is_registered'] = isRegistered; if (owner != null && owner.isNotEmpty) params['owner'] = owner; if (deviceType != null) { - params['device_type'] = - deviceType == DeviceType.unknown ? '' : deviceType.apiName; + params['device_type'] = deviceType == DeviceType.unknown + ? '' + : deviceType.apiName; } final response = await _dio.get('/devices', queryParameters: params); @@ -172,6 +174,13 @@ class BackendAPI { return ArpScannerStatus.fromJson(response.data as Map); } + Future getMdnsScannerStatus() async { + debugPrint('About to call GET /mdns_scanner/status'); + final response = await _dio.get('/mdns_scanner/status'); + debugPrint('Received: ${response.data}'); + return MdnsScannerStatus.fromJson(response.data as Map); + } + Future> listNotifications( bool? isNew, int offset, { diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart index b39740b..fe7a222 100644 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../model/arp_scanner_status.dart'; +import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; class ArpScannerCard extends StatefulWidget { @@ -25,16 +26,10 @@ class _ArpScannerCardState extends State { void initState() { super.initState(); _load(); - _refreshTimer = Timer.periodic( - const Duration(seconds: 5), - (_) => _load(), - ); - _tickTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - if (mounted) setState(() {}); - }, - ); + _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); } @override @@ -130,7 +125,7 @@ class _ArpScannerCardState extends State { if (_status!.isRunning) { final sub = _status!.runningForSeconds != null - ? 'Running for ${_formatSeconds(_status!.runningForSeconds! + elapsed)}' + ? 'Running for ${formatSeconds(_status!.runningForSeconds! + elapsed)}' : null; return (Colors.green, 'Running', sub); } @@ -142,17 +137,9 @@ class _ArpScannerCardState extends State { return ( Colors.amber, 'Waiting for next run', - 'Next run in ${_formatSeconds(remaining)}', + 'Next run in ${formatSeconds(remaining)}', ); } return (Colors.grey, 'Not yet started', null); } } - -String _formatSeconds(double seconds) { - final total = seconds.round().clamp(0, double.maxFinite.toInt()); - if (total < 60) return '${total}s'; - final m = total ~/ 60; - final s = total % 60; - return '${m}m ${s}s'; -} diff --git a/frontend/lib/widgets/mdns_scanner_card.dart b/frontend/lib/widgets/mdns_scanner_card.dart new file mode 100644 index 0000000..120de25 --- /dev/null +++ b/frontend/lib/widgets/mdns_scanner_card.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../model/mdns_scanner_status.dart'; +import '../utils/duration_formatter.dart'; +import '../utils/oott_api.dart'; + +class MdnsScannerCard extends StatefulWidget { + const MdnsScannerCard({super.key}); + + @override + State createState() => _MdnsScannerCardState(); +} + +class _MdnsScannerCardState extends State { + MdnsScannerStatus? _status; + DateTime? _statusReceivedAt; + bool _isLoading = true; + String? _error; + Timer? _refreshTimer; + Timer? _tickTimer; + + @override + void initState() { + super.initState(); + _load(); + _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _tickTimer?.cancel(); + super.dispose(); + } + + Future _load() async { + try { + final status = await BackendAPI.instance.getMdnsScannerStatus(); + if (!mounted) return; + setState(() { + _status = status; + _statusReceivedAt = DateTime.now(); + _error = null; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + final (color, label, sublabels) = _resolveState(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go('/status'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.circle, color: color, size: 14), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'mDNS Scanner', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + for (final sublabel in sublabels) ...[ + const SizedBox(height: 2), + Text( + sublabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + (Color, String, List) _resolveState(BuildContext context) { + if (_error != null || _status == null) { + return ( + Colors.red, + 'Error', + [ + 'Unable to reach the server or a server-side error occurred. Check the logs for details.', + ], + ); + } + + final elapsed = _statusReceivedAt != null + ? DateTime.now().difference(_statusReceivedAt!).inSeconds.toDouble() + : 0.0; + + if (_status!.isListening) { + final sublabels = []; + if (_status!.listeningForSeconds != null) { + sublabels.add( + 'Listening for ${formatSeconds(_status!.listeningForSeconds! + elapsed)} ยท ${_status!.devicesSeen} devices seen', + ); + } else { + sublabels.add('${_status!.devicesSeen} devices seen'); + } + if (_status!.lastDeviceSeenSecondsAgo != null) { + sublabels.add( + 'Last device ${formatSeconds(_status!.lastDeviceSeenSecondsAgo! + elapsed)} ago', + ); + } + return (Colors.green, 'Listening', sublabels); + } + + return (Colors.grey, 'Not yet started', []); + } +} diff --git a/frontend/lib/widgets/scanners_status_card.dart b/frontend/lib/widgets/scanners_status_card.dart new file mode 100644 index 0000000..c2f63a4 --- /dev/null +++ b/frontend/lib/widgets/scanners_status_card.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../model/arp_scanner_status.dart'; +import '../model/mdns_scanner_status.dart'; +import '../utils/duration_formatter.dart'; +import '../utils/oott_api.dart'; + +class ScannersStatusCard extends StatefulWidget { + const ScannersStatusCard({super.key}); + + @override + State createState() => _ScannersStatusCardState(); +} + +class _ScannersStatusCardState extends State { + ArpScannerStatus? _arpStatus; + String? _arpError; + MdnsScannerStatus? _mdnsStatus; + String? _mdnsError; + DateTime? _statusReceivedAt; + bool _isLoading = true; + Timer? _refreshTimer; + Timer? _tickTimer; + + @override + void initState() { + super.initState(); + _load(); + _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _tickTimer?.cancel(); + super.dispose(); + } + + Future _load() async { + ArpScannerStatus? arp; + String? arpError; + try { + arp = await BackendAPI.instance.getArpScannerStatus(); + } catch (e) { + arpError = e.toString(); + } + + MdnsScannerStatus? mdns; + String? mdnsError; + try { + mdns = await BackendAPI.instance.getMdnsScannerStatus(); + } catch (e) { + mdnsError = e.toString(); + } + + if (!mounted) return; + setState(() { + _arpStatus = arp; + _arpError = arpError; + _mdnsStatus = mdns; + _mdnsError = mdnsError; + _statusReceivedAt = DateTime.now(); + _isLoading = false; + }); + } + + double get _elapsed => _statusReceivedAt != null + ? DateTime.now().difference(_statusReceivedAt!).inSeconds.toDouble() + : 0.0; + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + final (arpColor, arpText) = _resolveArp(); + final (mdnsColor, mdnsText) = _resolveMdns(); + + 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), + const SizedBox(height: 8), + _scannerRow(context, mdnsColor, 'mDNS', mdnsText), + ], + ), + ), + ), + ); + } + + Widget _scannerRow( + BuildContext context, + Color color, + String name, + String statusText, + ) { + return Row( + children: [ + Icon(Icons.circle, color: color, size: 12), + const SizedBox(width: 10), + Expanded( + child: Text(name, style: Theme.of(context).textTheme.bodyMedium), + ), + const SizedBox(width: 8), + Text( + statusText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ); + } + + (Color, String) _resolveArp() { + if (_arpError != null || _arpStatus == null) { + return (Colors.red, 'Error'); + } + if (_arpStatus!.isRunning) { + final secs = (_arpStatus!.runningForSeconds ?? 0) + _elapsed; + return (Colors.green, 'Running for ${formatSeconds(secs)}'); + } + if (_arpStatus!.nextRunInSeconds != null) { + final remaining = (_arpStatus!.nextRunInSeconds! - _elapsed).clamp( + 0.0, + double.infinity, + ); + return (Colors.amber, 'Next run in ${formatSeconds(remaining)}'); + } + return (Colors.grey, 'Not yet started'); + } + + (Color, String) _resolveMdns() { + if (_mdnsError != null || _mdnsStatus == null) { + return (Colors.red, 'Error'); + } + if (_mdnsStatus!.isListening) { + if (_mdnsStatus!.lastDeviceSeenSecondsAgo != null) { + final secs = _mdnsStatus!.lastDeviceSeenSecondsAgo! + _elapsed; + return (Colors.green, 'Last device seen ${formatSeconds(secs)} ago'); + } + return (Colors.green, 'No devices seen yet'); + } + return (Colors.grey, 'Not yet started'); + } +}