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'); + } +}