From bb677aeebfbb9049c1b1a8bff6c300706403386e Mon Sep 17 00:00:00 2001 From: rzuasti Date: Sun, 31 May 2026 10:03:19 -0400 Subject: [PATCH] Centralize status polling with last-known-good, stale, and error tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four polling status cards (ARP, mDNS, scanners summary, devices summary) each rolled their own Timer/_status/_error/_isLoading state and flipped to a red 'Error' UI on the first failed poll, throwing away last-known-good data that was still in memory. A momentary backend blip made every card flash red. Introduce PolledValue, a ChangeNotifier that owns the periodic timer, in-flight CancelToken, last value, last error, and a freshness classification (initialLoading / fresh / stale / error). 'stale' kicks in on the first failure but keeps the value visible; 'error' only takes over after staleErrorAfter elapses without a success (30s for the 5s scanner polls, 3min for the 1min device-summary poll). All four cards now branch on freshness and show a small live-updating warning icon with a tooltip ('Last updated N ago — ') while stale, instead of flipping red. Also threads CancelToken? through the three polled GET endpoints. Co-Authored-By: Claude Opus 4.7 --- frontend/lib/utils/oott_api.dart | 25 ++- frontend/lib/utils/polled_value.dart | 76 ++++++++ frontend/lib/widgets/arp_scanner_card.dart | 165 ++++++++--------- frontend/lib/widgets/device_summary_card.dart | 168 ++++++++++-------- frontend/lib/widgets/mdns_scanner_card.dart | 167 ++++++++--------- .../lib/widgets/polled_stale_indicator.dart | 54 ++++++ .../lib/widgets/scanners_status_card.dart | 167 ++++++++--------- 7 files changed, 494 insertions(+), 328 deletions(-) create mode 100644 frontend/lib/utils/polled_value.dart create mode 100644 frontend/lib/widgets/polled_stale_indicator.dart diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 78bc8f8..ecd852f 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -235,18 +235,31 @@ class BackendAPI { .toList(); } - Future getDeviceSummary() async { - final response = await _dio.get('/devices/summary'); + Future getDeviceSummary({CancelToken? cancelToken}) async { + final response = await _dio.get( + '/devices/summary', + cancelToken: cancelToken, + ); return DeviceSummary.fromJson(response.data as Map); } - Future getArpScannerStatus() async { - final response = await _dio.get('/arp_scanner/status'); + Future getArpScannerStatus({ + CancelToken? cancelToken, + }) async { + final response = await _dio.get( + '/arp_scanner/status', + cancelToken: cancelToken, + ); return ArpScannerStatus.fromJson(response.data as Map); } - Future getMdnsScannerStatus() async { - final response = await _dio.get('/mdns_scanner/status'); + Future getMdnsScannerStatus({ + CancelToken? cancelToken, + }) async { + final response = await _dio.get( + '/mdns_scanner/status', + cancelToken: cancelToken, + ); return MdnsScannerStatus.fromJson(response.data as Map); } diff --git a/frontend/lib/utils/polled_value.dart b/frontend/lib/utils/polled_value.dart new file mode 100644 index 0000000..caabcda --- /dev/null +++ b/frontend/lib/utils/polled_value.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import 'oott_api.dart'; + +enum PolledFreshness { initialLoading, fresh, stale, error } + +class PolledValue extends ChangeNotifier { + PolledValue({ + required Future Function({CancelToken? cancelToken}) fetch, + required Duration pollInterval, + required Duration staleErrorAfter, + }) : _fetch = fetch, + _staleErrorAfter = staleErrorAfter { + _load(); + _pollTimer = Timer.periodic(pollInterval, (_) => _load()); + } + + final Future Function({CancelToken? cancelToken}) _fetch; + final Duration _staleErrorAfter; + Timer? _pollTimer; + CancelToken? _cancelToken; + bool _disposed = false; + + T? _value; + DateTime? _lastSuccessAt; + String? _lastErrorMessage; + bool _everCompleted = false; + + T? get value => _value; + DateTime? get lastSuccessAt => _lastSuccessAt; + String? get lastErrorMessage => _lastErrorMessage; + + PolledFreshness get freshness { + if (!_everCompleted && _value == null) { + return PolledFreshness.initialLoading; + } + final last = _lastSuccessAt; + if (_value == null || last == null) return PolledFreshness.error; + if (_lastErrorMessage == null) return PolledFreshness.fresh; + return DateTime.now().difference(last) >= _staleErrorAfter + ? PolledFreshness.error + : PolledFreshness.stale; + } + + Future _load() async { + _cancelToken?.cancel(); + final token = CancelToken(); + _cancelToken = token; + try { + final result = await _fetch(cancelToken: token); + if (_disposed || token != _cancelToken) return; + _value = result; + _lastSuccessAt = DateTime.now(); + _lastErrorMessage = null; + _everCompleted = true; + notifyListeners(); + } catch (e) { + if (_disposed || token != _cancelToken) return; + if (e is DioException && e.type == DioExceptionType.cancel) return; + _lastErrorMessage = dioErrorToUserMessage(e); + _everCompleted = true; + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + _pollTimer?.cancel(); + _cancelToken?.cancel(); + super.dispose(); + } +} diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart index fe7a222..7779c25 100644 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import '../model/arp_scanner_status.dart'; import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/polled_value.dart'; +import 'polled_stale_indicator.dart'; class ArpScannerCard extends StatefulWidget { const ArpScannerCard({super.key}); @@ -15,18 +17,18 @@ class ArpScannerCard extends StatefulWidget { } class _ArpScannerCardState extends State { - ArpScannerStatus? _status; - DateTime? _statusReceivedAt; - bool _isLoading = true; - String? _error; - Timer? _refreshTimer; + late final PolledValue _polled; Timer? _tickTimer; @override void initState() { super.initState(); - _load(); - _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _polled = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); @@ -34,103 +36,106 @@ class _ArpScannerCardState extends State { @override void dispose() { - _refreshTimer?.cancel(); _tickTimer?.cancel(); + _polled.dispose(); super.dispose(); } - Future _load() 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) { - if (_isLoading) { - return const Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ); - } + return ListenableBuilder( + listenable: _polled, + builder: (context, _) { + if (_polled.freshness == PolledFreshness.initialLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - final (color, label, sublabel) = _resolveState(context); + final (color, label, sublabel) = _resolveState(); + final isStale = _polled.freshness == PolledFreshness.stale; - 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( - '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, + 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: [ + Row( + children: [ + Text( + 'ARP Scanner', + style: Theme.of(context).textTheme.titleMedium, + ), + if (isStale) ...[ + const SizedBox(width: 6), + PolledStaleIndicator(polled: _polled), + ], + ], ), - ), - ], - ], - ), + 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) { + (Color, String, String?) _resolveState() { + if (_polled.freshness == PolledFreshness.error) { return ( Colors.red, 'Error', - 'Unable to reach the server or a server-side error occurred. Check the logs for details.', + _polled.lastErrorMessage ?? + 'Unable to reach the server. Check the logs for details.', ); } - final elapsed = _statusReceivedAt != null - ? DateTime.now().difference(_statusReceivedAt!).inSeconds.toDouble() - : 0.0; + final status = _polled.value!; + final lastSuccessAt = _polled.lastSuccessAt!; + final elapsed = DateTime.now() + .difference(lastSuccessAt) + .inSeconds + .toDouble(); - if (_status!.isRunning) { - final sub = _status!.runningForSeconds != null - ? 'Running for ${formatSeconds(_status!.runningForSeconds! + elapsed)}' + 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( + if (status.nextRunInSeconds != null) { + final remaining = (status.nextRunInSeconds! - elapsed).clamp( 0.0, double.infinity, ); diff --git a/frontend/lib/widgets/device_summary_card.dart b/frontend/lib/widgets/device_summary_card.dart index f935831..553224e 100644 --- a/frontend/lib/widgets/device_summary_card.dart +++ b/frontend/lib/widgets/device_summary_card.dart @@ -1,10 +1,10 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../model/device_summary.dart'; import '../utils/oott_api.dart'; +import '../utils/polled_value.dart'; +import 'polled_stale_indicator.dart'; class DeviceSummaryCard extends StatefulWidget { const DeviceSummaryCard({super.key}); @@ -14,42 +14,25 @@ class DeviceSummaryCard extends StatefulWidget { } class _DeviceSummaryCardState extends State { - DeviceSummary? _summary; - bool _isLoading = true; - String? _error; - Timer? _timer; + late final PolledValue _polled; @override void initState() { super.initState(); - _load(); - _timer = Timer.periodic(const Duration(minutes: 1), (_) => _load()); + _polled = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getDeviceSummary(cancelToken: cancelToken), + pollInterval: const Duration(minutes: 1), + staleErrorAfter: const Duration(minutes: 3), + ); } @override void dispose() { - _timer?.cancel(); + _polled.dispose(); super.dispose(); } - Future _load() async { - try { - final summary = await BackendAPI.instance.getDeviceSummary(); - if (!mounted) return; - setState(() { - _summary = summary; - _error = null; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - @override Widget build(BuildContext context) { return Card( @@ -58,64 +41,91 @@ class _DeviceSummaryCardState extends State { onTap: () => context.go('/devices'), child: Padding( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Devices', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Text( - 'Error loading device summary', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ) - else if (_summary != null) ...[ - _SummaryRow( - label: 'Registered in the system', - value: '${_summary!.totalRegistered}', - ), - const Divider(height: 20), - Text( - 'Seen in the last 24 hours', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, + child: ListenableBuilder( + listenable: _polled, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Devices', + style: Theme.of(context).textTheme.titleLarge, + ), + if (_polled.freshness == PolledFreshness.stale) ...[ + const SizedBox(width: 6), + PolledStaleIndicator(polled: _polled), + ], + ], ), - ), - const SizedBox(height: 6), - _SummaryRow( - label: 'Registered', - value: '${_summary!.seenLastDayRegistered}', - ), - _SummaryRow( - label: 'Unregistered', - value: '${_summary!.seenLastDayUnregistered}', - ), - const Divider(height: 20), - Text( - 'Seen in the last 7 days', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - _SummaryRow( - label: 'Registered', - value: '${_summary!.seenLastWeekRegistered}', - ), - _SummaryRow( - label: 'Unregistered', - value: '${_summary!.seenLastWeekUnregistered}', - ), - ], - ], + const SizedBox(height: 12), + ..._buildBody(context), + ], + ); + }, ), ), ), ); } + + List _buildBody(BuildContext context) { + switch (_polled.freshness) { + case PolledFreshness.initialLoading: + return const [Center(child: CircularProgressIndicator())]; + case PolledFreshness.error: + return [ + Text( + _polled.lastErrorMessage ?? 'Error loading device summary', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ]; + case PolledFreshness.fresh: + case PolledFreshness.stale: + final summary = _polled.value!; + return [ + _SummaryRow( + label: 'Registered in the system', + value: '${summary.totalRegistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 24 hours', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${summary.seenLastDayRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${summary.seenLastDayUnregistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 7 days', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${summary.seenLastWeekRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${summary.seenLastWeekUnregistered}', + ), + ]; + } + } } class _SummaryRow extends StatelessWidget { diff --git a/frontend/lib/widgets/mdns_scanner_card.dart b/frontend/lib/widgets/mdns_scanner_card.dart index 120de25..9d63c92 100644 --- a/frontend/lib/widgets/mdns_scanner_card.dart +++ b/frontend/lib/widgets/mdns_scanner_card.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import '../model/mdns_scanner_status.dart'; import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/polled_value.dart'; +import 'polled_stale_indicator.dart'; class MdnsScannerCard extends StatefulWidget { const MdnsScannerCard({super.key}); @@ -15,18 +17,18 @@ class MdnsScannerCard extends StatefulWidget { } class _MdnsScannerCardState extends State { - MdnsScannerStatus? _status; - DateTime? _statusReceivedAt; - bool _isLoading = true; - String? _error; - Timer? _refreshTimer; + late final PolledValue _polled; Timer? _tickTimer; @override void initState() { super.initState(); - _load(); - _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _polled = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); @@ -34,109 +36,112 @@ class _MdnsScannerCardState extends State { @override void dispose() { - _refreshTimer?.cancel(); _tickTimer?.cancel(); + _polled.dispose(); 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()), - ), - ); - } + return ListenableBuilder( + listenable: _polled, + builder: (context, _) { + if (_polled.freshness == PolledFreshness.initialLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - final (color, label, sublabels) = _resolveState(context); + final (color, label, sublabels) = _resolveState(); + final isStale = _polled.freshness == PolledFreshness.stale; - 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, + 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: [ + Row( + children: [ + Text( + 'mDNS Scanner', + style: Theme.of(context).textTheme.titleMedium, + ), + if (isStale) ...[ + const SizedBox(width: 6), + PolledStaleIndicator(polled: _polled), + ], + ], ), - ), - ], - ], - ), + 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) { + (Color, String, List) _resolveState() { + if (_polled.freshness == PolledFreshness.error) { return ( Colors.red, 'Error', [ - 'Unable to reach the server or a server-side error occurred. Check the logs for details.', + _polled.lastErrorMessage ?? + 'Unable to reach the server. Check the logs for details.', ], ); } - final elapsed = _statusReceivedAt != null - ? DateTime.now().difference(_statusReceivedAt!).inSeconds.toDouble() - : 0.0; + final status = _polled.value!; + final lastSuccessAt = _polled.lastSuccessAt!; + final elapsed = DateTime.now() + .difference(lastSuccessAt) + .inSeconds + .toDouble(); - if (_status!.isListening) { + if (status.isListening) { final sublabels = []; - if (_status!.listeningForSeconds != null) { + if (status.listeningForSeconds != null) { sublabels.add( - 'Listening for ${formatSeconds(_status!.listeningForSeconds! + elapsed)} · ${_status!.devicesSeen} devices seen', + 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen', ); } else { - sublabels.add('${_status!.devicesSeen} devices seen'); + sublabels.add('${status.devicesSeen} devices seen'); } - if (_status!.lastDeviceSeenSecondsAgo != null) { + if (status.lastDeviceSeenSecondsAgo != null) { sublabels.add( - 'Last device ${formatSeconds(_status!.lastDeviceSeenSecondsAgo! + elapsed)} ago', + 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', ); } return (Colors.green, 'Listening', sublabels); diff --git a/frontend/lib/widgets/polled_stale_indicator.dart b/frontend/lib/widgets/polled_stale_indicator.dart new file mode 100644 index 0000000..5baafb3 --- /dev/null +++ b/frontend/lib/widgets/polled_stale_indicator.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../utils/duration_formatter.dart'; +import '../utils/polled_value.dart'; + +class PolledStaleIndicator extends StatefulWidget { + const PolledStaleIndicator({super.key, required this.polled}); + + final PolledValue polled; + + @override + State createState() => _PolledStaleIndicatorState(); +} + +class _PolledStaleIndicatorState extends State { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final lastSuccessAt = widget.polled.lastSuccessAt; + final ago = lastSuccessAt != null + ? formatSeconds( + DateTime.now().difference(lastSuccessAt).inSeconds.toDouble(), + ) + : 'a while'; + final message = widget.polled.lastErrorMessage != null + ? 'Last updated $ago ago — ${widget.polled.lastErrorMessage}' + : 'Last updated $ago ago'; + return Tooltip( + message: message, + child: Icon( + Icons.warning_amber_rounded, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/frontend/lib/widgets/scanners_status_card.dart b/frontend/lib/widgets/scanners_status_card.dart index 94c8819..5f03e91 100644 --- a/frontend/lib/widgets/scanners_status_card.dart +++ b/frontend/lib/widgets/scanners_status_card.dart @@ -7,6 +7,8 @@ import '../model/arp_scanner_status.dart'; import '../model/mdns_scanner_status.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}); @@ -16,20 +18,25 @@ class ScannersStatusCard extends StatefulWidget { } class _ScannersStatusCardState extends State { - ArpScannerStatus? _arpStatus; - String? _arpError; - MdnsScannerStatus? _mdnsStatus; - String? _mdnsError; - DateTime? _statusReceivedAt; - bool _isLoading = true; - Timer? _refreshTimer; + late final PolledValue _arp; + late final PolledValue _mdns; Timer? _tickTimer; @override void initState() { super.initState(); - _load(); - _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); + _arp = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); + _mdns = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); @@ -37,75 +44,53 @@ class _ScannersStatusCardState extends State { @override void dispose() { - _refreshTimer?.cancel(); _tickTimer?.cancel(); + _arp.dispose(); + _mdns.dispose(); super.dispose(); } - Future _load() async { - ArpScannerStatus? arp; - String? arpError; - try { - arp = await BackendAPI.instance.getArpScannerStatus(); - } catch (e) { - arpError = dioErrorToUserMessage(e); - } - - MdnsScannerStatus? mdns; - String? mdnsError; - try { - mdns = await BackendAPI.instance.getMdnsScannerStatus(); - } catch (e) { - mdnsError = dioErrorToUserMessage(e); - } - - 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()), - ), - ); - } + return ListenableBuilder( + listenable: Listenable.merge([_arp, _mdns]), + builder: (context, _) { + if (_arp.freshness == PolledFreshness.initialLoading || + _mdns.freshness == PolledFreshness.initialLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - final (arpColor, arpText) = _resolveArp(); - final (mdnsColor, mdnsText) = _resolveMdns(); + 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, _arpError), - const SizedBox(height: 8), - _scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdnsError), - ], + 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), + const SizedBox(height: 8), + _scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdns), + ], + ), + ), ), - ), - ), + ); + }, ); } @@ -114,18 +99,26 @@ class _ScannersStatusCardState extends State { Color color, String name, String statusText, - String? errorMessage, + PolledValue polled, ) { Widget dot = Icon(Icons.circle, color: color, size: 12); - if (errorMessage != null) { - dot = Tooltip(message: errorMessage, child: dot); + if (polled.freshness == PolledFreshness.error) { + dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot); } return Row( children: [ dot, const SizedBox(width: 10), Expanded( - child: Text(name, style: Theme.of(context).textTheme.bodyMedium), + child: Row( + children: [ + Text(name, style: Theme.of(context).textTheme.bodyMedium), + if (polled.freshness == PolledFreshness.stale) ...[ + const SizedBox(width: 6), + PolledStaleIndicator(polled: polled), + ], + ], + ), ), const SizedBox(width: 8), Text( @@ -139,15 +132,20 @@ class _ScannersStatusCardState extends State { } (Color, String) _resolveArp() { - if (_arpError != null || _arpStatus == null) { + if (_arp.freshness == PolledFreshness.error) { return (Colors.red, 'Error'); } - if (_arpStatus!.isRunning) { - final secs = (_arpStatus!.runningForSeconds ?? 0) + _elapsed; + final status = _arp.value!; + final elapsed = DateTime.now() + .difference(_arp.lastSuccessAt!) + .inSeconds + .toDouble(); + if (status.isRunning) { + final secs = (status.runningForSeconds ?? 0) + elapsed; return (Colors.green, 'Running for ${formatSeconds(secs)}'); } - if (_arpStatus!.nextRunInSeconds != null) { - final remaining = (_arpStatus!.nextRunInSeconds! - _elapsed).clamp( + if (status.nextRunInSeconds != null) { + final remaining = (status.nextRunInSeconds! - elapsed).clamp( 0.0, double.infinity, ); @@ -157,12 +155,17 @@ class _ScannersStatusCardState extends State { } (Color, String) _resolveMdns() { - if (_mdnsError != null || _mdnsStatus == null) { + if (_mdns.freshness == PolledFreshness.error) { return (Colors.red, 'Error'); } - if (_mdnsStatus!.isListening) { - if (_mdnsStatus!.lastDeviceSeenSecondsAgo != null) { - final secs = _mdnsStatus!.lastDeviceSeenSecondsAgo! + _elapsed; + 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 (Colors.green, 'Last device seen ${formatSeconds(secs)} ago'); } return (Colors.green, 'No devices seen yet');