diff --git a/frontend/lib/model/arp_scanner_status.dart b/frontend/lib/model/arp_scanner_status.dart new file mode 100644 index 0000000..fcddc88 --- /dev/null +++ b/frontend/lib/model/arp_scanner_status.dart @@ -0,0 +1,19 @@ +class ArpScannerStatus { + final bool isRunning; + final double? runningForSeconds; + final double? nextRunInSeconds; + + const ArpScannerStatus({ + required this.isRunning, + this.runningForSeconds, + this.nextRunInSeconds, + }); + + factory ArpScannerStatus.fromJson(Map json) { + return ArpScannerStatus( + isRunning: json['is_running'] as bool, + runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), + nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(), + ); + } +} diff --git a/frontend/lib/navigation.dart b/frontend/lib/navigation.dart index d1a97e1..37bde3b 100644 --- a/frontend/lib/navigation.dart +++ b/frontend/lib/navigation.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'devices/device_detail.dart'; import 'devices/device_list.dart'; import 'notifications/notification_list.dart'; +import 'status/status_screen.dart'; import 'utils/pref_utils.dart'; // Routes definitions @@ -40,6 +41,12 @@ final GoRouter router = GoRouter( ), ], ), + GoRoute( + path: '/status', + name: 'status', + builder: (context, state) => const StatusScreen(), + redirect: (context, state) => _redirectToSettings(), + ), GoRoute( path: '/settings', name: 'settings', @@ -105,6 +112,11 @@ class MainShell extends StatelessWidget { selectedIcon: Icon(Icons.devices_other), label: Text('Devices'), ), + NavigationRailDestination( + icon: Icon(Icons.monitor_heart_outlined), + selectedIcon: Icon(Icons.monitor_heart), + label: Text('Status'), + ), NavigationRailDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), @@ -146,8 +158,9 @@ int _calculateSelectedIndex(BuildContext context) { final location = GoRouterState.of(context).uri.path; if (location.startsWith('/notifications')) return 0; if (location.startsWith('/devices')) return 1; - if (location.startsWith('/settings')) return 2; - if (location.startsWith('/about')) return 3; + if (location.startsWith('/status')) return 2; + if (location.startsWith('/settings')) return 3; + if (location.startsWith('/about')) return 4; return 0; } @@ -160,9 +173,12 @@ void _onDestinationSelected(int index, BuildContext context) { context.go('/devices'); break; case 2: - context.go('/settings'); + context.go('/status'); break; case 3: + context.go('/settings'); + break; + case 4: context.go('/about'); break; } diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart new file mode 100644 index 0000000..f52f623 --- /dev/null +++ b/frontend/lib/status/status_screen.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:frontend/model/arp_scanner_status.dart'; +import 'package:frontend/utils/oott_api.dart'; + +class StatusScreen extends StatefulWidget { + const StatusScreen({super.key}); + + @override + State createState() => _StatusScreenState(); +} + +class _StatusScreenState extends State { + ArpScannerStatus? _status; + DateTime? _statusReceivedAt; + bool _isLoading = true; + String? _error; + Timer? _refreshTimer; + Timer? _tickTimer; + + @override + void initState() { + super.initState(); + _loadStatus(); + _refreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _loadStatus(), + ); + _tickTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { if (mounted) setState(() {}); }, + ); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _tickTimer?.cancel(); + super.dispose(); + } + + Future _loadStatus() async { + try { + final status = await BackendAPI.instance.getArpScannerStatus(); + 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) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Status', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 20), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else + _ArpScannerCard( + status: _status, + statusReceivedAt: _statusReceivedAt, + error: _error, + ), + ], + ); + } +} + +class _ArpScannerCard extends StatelessWidget { + final ArpScannerStatus? status; + final DateTime? statusReceivedAt; + final String? error; + + const _ArpScannerCard({this.status, this.statusReceivedAt, this.error}); + + @override + Widget build(BuildContext context) { + final (color, label, sublabel) = _resolveState(context); + + return Card( + 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( + 'ARP Scanner', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + if (sublabel != null) ...[ + const SizedBox(height: 2), + Text( + sublabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + (Color, String, String?) _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!.isRunning) { + final sub = status!.runningForSeconds != null + ? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}' + : null; + return (Colors.green, 'Running', sub); + } + if (status!.nextRunInSeconds != null) { + final remaining = (status!.nextRunInSeconds! - elapsed).clamp(0.0, double.infinity); + return ( + Colors.amber, + 'Waiting for next run', + '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/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 15d2930..43d4ba3 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; 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/device.dart'; import '../model/device_event.dart'; import '../model/device_type.dart'; @@ -156,6 +157,13 @@ class BackendAPI { .toList(); } + Future getArpScannerStatus() async { + debugPrint('About to call GET /arp_scanner/status'); + final response = await _dio.get('/arp_scanner/status'); + debugPrint('Received: ${response.data}'); + return ArpScannerStatus.fromJson(response.data as Map); + } + Future> listNotifications(bool? isNew, int offset) async { debugPrint('About to call /notifications');