diff --git a/lib/core/motion/polling_controller.dart b/lib/core/motion/polling_controller.dart new file mode 100644 index 00000000..0e60d8f7 --- /dev/null +++ b/lib/core/motion/polling_controller.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// What the last polls did: when one last succeeded and how many failed since. +@immutable +class PollStatus { + const PollStatus({this.lastSuccess, this.lastError, this.failures = 0}); + + final DateTime? lastSuccess; + final Object? lastError; + + /// Consecutive failed ticks; zero after a success. + final int failures; + + /// The latest tick failed, so what the view shows is older than it looks. + bool get isStale => failures > 0; +} + +/// Polls with [tick] every [interval], one request at a time. +/// +/// The next tick is scheduled when the previous one has finished, so a slow +/// server is never asked again while it is still answering, and results land +/// in the order they were asked for. A failed tick backs the interval off +/// exponentially (up to [maxBackoff]) and is reported through [status]; the +/// first success returns to [interval]. +/// +/// Start and stop with [setActive], from `didChangeDependencies` (reading +/// `TickerMode`) and from the places that change whether the view has data, +/// not from `build`. +class PollingController { + PollingController({ + required Duration interval, + required this.tick, + this.maxBackoff = const Duration(minutes: 1), + }) : _interval = interval; + + /// One poll. Throw to report a failure. + final Future Function() tick; + final Duration maxBackoff; + + final ValueNotifier status = + ValueNotifier(const PollStatus()); + + Duration _interval; + Timer? _timer; + bool _active = false; + bool _inFlight = false; + bool _disposed = false; + + /// Bumped on every stop, so a tick that was running when polling stopped + /// does not schedule the next one or report into the new session. + int _epoch = 0; + + Duration get interval => _interval; + bool get isActive => _active; + + /// The wait before the next tick: [interval], doubled for each failure. + Duration get nextDelay { + final failures = status.value.failures; + if (failures == 0 || _interval >= maxBackoff) return _interval; + final micros = _interval.inMicroseconds * (1 << math.min(failures, 16)); + return Duration(microseconds: math.min(micros, maxBackoff.inMicroseconds)); + } + + void setActive(bool active) { + if (_disposed || active == _active) return; + _active = active; + _epoch++; + if (active) { + _schedule(); + } else { + _timer?.cancel(); + _timer = null; + } + } + + void setInterval(Duration interval) { + if (interval == _interval) return; + _interval = interval; + if (_active && !_inFlight) _schedule(); + } + + /// Forgets what the previous session reported (a reconnect, a retry). + void reset() { + if (_disposed) return; + status.value = const PollStatus(); + } + + void _schedule() { + _timer?.cancel(); + _timer = Timer(nextDelay, _run); + } + + Future _run() async { + _timer = null; + if (_disposed || !_active) return; + if (_inFlight) { + // A tick from before a stop/start is still answering. + _schedule(); + return; + } + final epoch = _epoch; + _inFlight = true; + try { + await tick(); + if (_disposed || epoch != _epoch) return; + status.value = PollStatus(lastSuccess: DateTime.now()); + } catch (e) { + if (_disposed || epoch != _epoch) return; + final previous = status.value; + status.value = PollStatus( + lastSuccess: previous.lastSuccess, + lastError: e, + failures: previous.failures + 1, + ); + } finally { + _inFlight = false; + } + if (!_disposed && _active && epoch == _epoch) _schedule(); + } + + void dispose() { + _disposed = true; + _timer?.cancel(); + _timer = null; + status.dispose(); + } +} + +/// Wires a [PollingController] to a [State]: polls while the view is under an +/// enabled `TickerMode` (not an offscreen keep-alive layer), has data to +/// refresh ([pollShouldRun]) and has an interval ([pollInterval], null = off). +/// +/// Call [syncPolling] whenever the answer to [pollShouldRun] or [pollInterval] +/// may have changed (a load finished or failed, the interval was picked) and +/// [stopPolling] when the data is dropped (a reload, another connection). +mixin PollingState on State { + Duration? get pollInterval; + bool get pollShouldRun; + + /// One poll; throw to report a failure to [PollingController.status]. + Future pollTick(); + + late final PollingController polling = PollingController( + interval: pollInterval ?? const Duration(seconds: 5), + tick: pollTick, + ); + + bool _tickerEnabled = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _tickerEnabled = TickerMode.valuesOf(context).enabled; + syncPolling(); + } + + void syncPolling() { + final interval = pollInterval; + if (interval != null) polling.setInterval(interval); + polling.setActive( + mounted && _tickerEnabled && interval != null && pollShouldRun, + ); + } + + void stopPolling() { + polling + ..setActive(false) + ..reset(); + } + + @override + void dispose() { + polling.dispose(); + super.dispose(); + } +} diff --git a/lib/core/motion/ticker_gated_polling.dart b/lib/core/motion/ticker_gated_polling.dart deleted file mode 100644 index 0838fe55..00000000 --- a/lib/core/motion/ticker_gated_polling.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; - -/// Starts/stops a [Timer.periodic] based on [TickerMode] (keep-alive morph). -/// -/// Call from [State.build] or [State.didChangeDependencies]. When the ancestor -/// [TickerMode] is disabled (inactive [QueryaSwitchingBody] / -/// [QueryaCrossFadeStack] layer), the timer is cancelled so network polls do -/// not run offscreen. Re-enabling restarts the timer when [shouldRun] is true. -Timer? syncTickerGatedPeriodicTimer({ - required BuildContext context, - required Timer? timer, - required bool shouldRun, - required Duration interval, - required void Function() onTick, -}) { - final active = TickerMode.valuesOf(context).enabled && shouldRun; - if (!active) { - timer?.cancel(); - return null; - } - return timer ?? Timer.periodic(interval, (_) => onTick()); -} diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index e274e5fd..dd9052e6 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -1,10 +1,9 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -31,11 +30,11 @@ class ExtensionStatsView extends material.StatefulWidget { material.State createState() => _ExtensionStatsViewState(); } -class _ExtensionStatsViewState extends material.State { +class _ExtensionStatsViewState extends material.State + with PollingState { ExtensionServerStats? _stats; bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -47,19 +46,13 @@ class _ExtensionStatsViewState extends material.State { void didUpdateWidget(covariant ExtensionStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _load(); } } - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - Future _load() async { - _timer?.cancel(); + stopPolling(); if (!mounted) return; setState(() { _loading = true; @@ -68,13 +61,14 @@ class _ExtensionStatsViewState extends material.State { }); try { await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -97,35 +91,28 @@ class _ExtensionStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } - Future _onPollTick() async { - try { - final stats = await ExtensionDriverSession.instance - .getServerStats(widget.connectionRow); - if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() => _stats = stats); - } catch (_) {} - } + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun => !_loading && _error == null; - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final stats = await ExtensionDriverSession.instance + .getServerStats(widget.connectionRow); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() => _stats = stats); } @override material.Widget build(material.BuildContext context) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: !_loading && _error == null, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - final cs = Theme.of(context).colorScheme; if (_loading) { @@ -168,39 +155,42 @@ class _ExtensionStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - if (stats.databaseSizes.isNotEmpty) ...[ - _databasesCard(context, stats), + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), const Gap(24), + if (stats.databaseSizes.isNotEmpty) ...[ + _databasesCard(context, stats), + const Gap(24), + ], + if (stats.extraMetrics.isNotEmpty) + _extraMetricsCard(context, stats), ], - if (stats.extraMetrics.isNotEmpty) - _extraMetricsCard(context, stats), - ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 8da265e3..eeb4dc88 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,8 +1,7 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; @@ -47,12 +46,12 @@ class MongoStatsView extends material.StatefulWidget { material.State createState() => _MongoStatsViewState(); } -class _MongoStatsViewState extends material.State { +class _MongoStatsViewState extends material.State + with PollingState { MongoConnection? _connection; Map? _serverStatus; bool _loading = true; String? _error; - Timer? _timer; /// `null` = auto-refresh off. Default matches previous 3s polling. Duration? _autoRefreshInterval = _defaultAutoRefresh; @@ -70,7 +69,7 @@ class _MongoStatsViewState extends material.State { void didUpdateWidget(covariant MongoStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -78,7 +77,6 @@ class _MongoStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -89,7 +87,7 @@ class _MongoStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -112,13 +110,14 @@ class _MongoStatsViewState extends material.State { } _connection = conn; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -146,6 +145,7 @@ class _MongoStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -165,30 +165,28 @@ class _MongoStatsViewState extends material.State { } } - Future _pollTick() async { + @override + Duration? get pollInterval => _autoRefreshInterval; + + @override + bool get pollShouldRun => !_loading && _error == null && _connection != null; + + /// A failed auto-refresh keeps the last good snapshot; the polling status + /// reports it. + @override + Future pollTick() async { final c = _connection; if (c == null || !c.isConnected) return; - try { - final status = await MongoService.instance.executeCommand( - c, - 'admin', - {'serverStatus': 1}, - ); - if (!mounted) return; - if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { - return; - } - setState(() => _lastFetchedAt = DateTime.now()); - } catch (_) { - // Keep last good snapshot on transient errors during auto-refresh. + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { + return; } - } - - void _startTimer() { - _timer?.cancel(); - final interval = _autoRefreshInterval; - if (interval == null) return; - _timer = Timer.periodic(interval, (_) => unawaited(_pollTick())); + setState(() => _lastFetchedAt = DateTime.now()); } String _formatClock(DateTime t) { @@ -198,37 +196,26 @@ class _MongoStatsViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final interval = _autoRefreshInterval; - if (interval != null) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: !_loading && _connection != null, - interval: interval, - onTick: () => unawaited(_pollTick()), - ); - } else { - _timer?.cancel(); - _timer = null; - } - final cs = Theme.of(context).colorScheme; - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), - child: _header(context), - ), - const QueryaDivider(height: 1), - material.Expanded( - child: _buildBody(context, cs), - ), - ], - ), + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), + child: _header(context), + ), + const QueryaDivider(height: 1), + material.Expanded( + child: _buildBody(context, cs), + ), + ], + ), + ), ); } @@ -464,7 +451,7 @@ class _MongoStatsViewState extends material.State { ], onSelected: (value) { setState(() => _autoRefreshInterval = value); - _startTimer(); + syncPolling(); }, ), ), diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index dcb4bfd8..68ccf297 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,8 +1,7 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; @@ -31,7 +30,8 @@ class MysqlStatsView extends material.StatefulWidget { material.State createState() => _MysqlStatsViewState(); } -class _MysqlStatsViewState extends material.State { +class _MysqlStatsViewState extends material.State + with PollingState { MysqlLease? _lease; Map? _stats; @@ -39,7 +39,6 @@ class _MysqlStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -51,7 +50,7 @@ class _MysqlStatsViewState extends material.State { void didUpdateWidget(covariant MysqlStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnect(); _load(); } @@ -59,7 +58,6 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnect(); super.dispose(); } @@ -70,7 +68,7 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnect(); if (!mounted) return; setState(() { @@ -90,13 +88,14 @@ class _MysqlStatsViewState extends material.State { } _lease = lease; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } @@ -108,6 +107,7 @@ class _MysqlStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -124,6 +124,7 @@ class _MysqlStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } @@ -140,35 +141,29 @@ class _MysqlStatsViewState extends material.State { 'queries_per_second', statusValue('Questions'), now); } - Future _onPollTick() async { + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun { final conn = _lease?.connection; - if (conn == null || !conn.isConnected) return; - try { - final stats = await conn.serverStats(); - if (!mounted) return; - _recordHistory(stats); - // The sparklines move with every sample, so rebuild even when the numbers - // did not change. - setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); - } catch (_) {} + return conn != null && conn.isConnected && !_loading && _error == null; } - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + final stats = await conn.serverStats(); + if (!mounted) return; + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } @override material.Widget build(material.BuildContext context) { - final conn = _lease?.connection; - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: conn != null && conn.isConnected && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - final cs = Theme.of(context).colorScheme; if (_loading) { @@ -217,47 +212,50 @@ class _MysqlStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - final isNarrow = contentWidth < 560; - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - _gridRow( - _connectionsCard(context, stats), - _queriesCard(context, stats), - isNarrow: isNarrow, - ), - const Gap(16), - _gridRow( - _networkCard(context, stats), - _settingsCard(context, stats), - isNarrow: isNarrow, - ), - const Gap(24), - _databasesCard(context, stats), - ], + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + final isNarrow = contentWidth < 560; + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + _gridRow( + _connectionsCard(context, stats), + _queriesCard(context, stats), + isNarrow: isNarrow, + ), + const Gap(16), + _gridRow( + _networkCard(context, stats), + _settingsCard(context, stats), + isNarrow: isNarrow, + ), + const Gap(24), + _databasesCard(context, stats), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 1a5a8e01..0381db13 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,8 +1,7 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; @@ -56,7 +55,8 @@ void recordServerStats( history.recordCounter('transactions_per_second', commits.toDouble(), now); } -class _PostgresStatsViewState extends material.State { +class _PostgresStatsViewState extends material.State + with PollingState { PgLease? _lease; PostgresConnection? get _connection => _lease?.connection; @@ -66,7 +66,6 @@ class _PostgresStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -78,7 +77,7 @@ class _PostgresStatsViewState extends material.State { void didUpdateWidget(covariant PostgresStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -86,7 +85,6 @@ class _PostgresStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -99,7 +97,7 @@ class _PostgresStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -109,7 +107,7 @@ class _PostgresStatsViewState extends material.State { }); if (widget.statsSource != null) { await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); return; } try { @@ -124,13 +122,14 @@ class _PostgresStatsViewState extends material.State { } _lease = lease; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -155,6 +154,7 @@ class _PostgresStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -178,37 +178,30 @@ class _PostgresStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } - Future _onPollTick() async { - if (!_canPoll) return; - try { - final stats = await _serverStats(); - if (!mounted) return; - _recordHistory(stats); - // The sparklines move with every sample, so rebuild even when the numbers - // did not change. - setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); - } catch (_) {} - } + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun => _canPoll && !_loading && _error == null; - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + if (!_canPoll) return; + final stats = await _serverStats(); + if (!mounted) return; + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } @override material.Widget build(material.BuildContext context) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: _canPoll && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - final cs = Theme.of(context).colorScheme; if (_loading) { @@ -251,56 +244,59 @@ class _PostgresStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - final isNarrow = contentWidth < 560; - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - if (isNarrow) - material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _connectionsCard(context, stats), - const Gap(16), - _serverSettingsCard(context, stats), - ], - ) - else - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded( - child: _connectionsCard(context, stats)), - const Gap(16), - material.Expanded( - child: _serverSettingsCard(context, stats)), - ], - ), - const Gap(16), - _databasesCard(context, stats), - ], + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + final isNarrow = contentWidth < 560; + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + if (isNarrow) + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _connectionsCard(context, stats), + const Gap(16), + _serverSettingsCard(context, stats), + ], + ) + else + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _connectionsCard(context, stats)), + const Gap(16), + material.Expanded( + child: _serverSettingsCard(context, stats)), + ], + ), + const Gap(16), + _databasesCard(context, stats), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 40f46a16..5bb5a87f 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; @@ -74,12 +74,12 @@ class RedisView extends material.StatefulWidget { material.State createState() => _RedisViewState(); } -class _RedisViewState extends material.State { +class _RedisViewState extends material.State + with PollingState { RedisConnection? _connection; RedisInfoSections? _info; bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -91,7 +91,7 @@ class _RedisViewState extends material.State { void didUpdateWidget(covariant RedisView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -99,7 +99,6 @@ class _RedisViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -118,7 +117,7 @@ class _RedisViewState extends material.State { } Future _load({bool reconnect = false}) async { - _timer?.cancel(); + stopPolling(); if (!mounted) return; setState(() { _loading = true; @@ -160,13 +159,14 @@ class _RedisViewState extends material.State { } _connection = conn; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -193,51 +193,48 @@ class _RedisViewState extends material.State { setState(() => _loading = false); } - Future _onPollTick() async { + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun { final c = _connection; - if (c == null || !c.isConnected) return; - try { - final raw = await c.info(); - final info = parseRedisInfo(raw); - if (!mounted) return; - if (!replaceIfChanged(_info, info, (v) => _info = v)) return; - setState(() => _info = info); - } catch (_) {} + return c != null && c.isConnected && !_loading && _error == null; } - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final c = _connection; + if (c == null || !c.isConnected) return; + final raw = await c.info(); + final info = parseRedisInfo(raw); + if (!mounted) return; + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() => _info = info); } @override material.Widget build(material.BuildContext context) { - final c = _connection; - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: c != null && c.isConnected && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - final cs = Theme.of(context).colorScheme; - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), - child: _header(context), - ), - const QueryaDivider(height: 1), - material.Expanded( - child: _buildBody(context, cs), - ), - ], - ), + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), + child: _header(context), + ), + const QueryaDivider(height: 1), + material.Expanded( + child: _buildBody(context, cs), + ), + ], + ), + ), ); } diff --git a/lib/shared/widgets/poll_stale_overlay.dart b/lib/shared/widgets/poll_stale_overlay.dart new file mode 100644 index 00000000..a4d24e52 --- /dev/null +++ b/lib/shared/widgets/poll_stale_overlay.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; + +/// Shows [child] with a small "out of date" note in the top-right corner while +/// the latest poll failed, so a dashboard that kept its last good numbers does +/// not look live. +class PollStaleOverlay extends StatelessWidget { + const PollStaleOverlay({super.key, required this.status, required this.child}); + + final ValueListenable status; + final Widget child; + + static String message(PollStatus s) { + final at = s.lastSuccess; + if (at == null) return 'Updates failed'; + String two(int n) => n.toString().padLeft(2, '0'); + return 'Updates failed ยท showing ${two(at.hour)}:${two(at.minute)}:${two(at.second)}'; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 8, + right: 16, + child: ValueListenableBuilder( + valueListenable: status, + builder: (context, s, _) { + if (!s.isStale) return const SizedBox.shrink(); + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: '${s.lastError}', + child: DecoratedBox( + key: const ValueKey('poll_stale_hint'), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + message(s), + style: TextStyle( + fontSize: 12, + color: scheme.onErrorContainer, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index caee584b..44aced82 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -10,6 +10,7 @@ library; export 'app_dialog.dart'; export 'app_toast.dart'; +export 'poll_stale_overlay.dart'; export 'export_menu_button.dart'; export 'querya_dialog_card.dart'; export 'querya_tab_strip.dart'; diff --git a/test/core/motion/polling_controller_test.dart b/test/core/motion/polling_controller_test.dart new file mode 100644 index 00000000..f93cdae0 --- /dev/null +++ b/test/core/motion/polling_controller_test.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; +import 'package:querya_desktop/shared/widgets/poll_stale_overlay.dart'; + +class _Host extends StatefulWidget { + const _Host({required this.onTick}); + + final VoidCallback onTick; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with PollingState<_Host> { + @override + Duration? get pollInterval => const Duration(milliseconds: 100); + + @override + bool get pollShouldRun => true; + + @override + Future pollTick() async => widget.onTick(); + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + const second = Duration(seconds: 1); + + group('PollingController', () { + testWidgets('a slow tick is never overlapped by the next one', + (tester) async { + var started = 0; + var running = 0; + var maxRunning = 0; + final gate = >[]; + final c = PollingController( + interval: second, + tick: () async { + started++; + running++; + maxRunning = running > maxRunning ? running : maxRunning; + final done = Completer(); + gate.add(done); + await done.future; + running--; + }, + ); + + c.setActive(true); + await tester.pump(second); + expect(started, 1); + + await tester.pump(const Duration(seconds: 30)); + expect(started, 1, reason: 'no new request while one is in flight'); + + gate.single.complete(); + await tester.pump(); + await tester.pump(second); + expect(started, 2); + expect(maxRunning, 1); + c.dispose(); + }); + + testWidgets('failures back off up to the cap and a success recovers', + (tester) async { + var calls = 0; + var fail = true; + final c = PollingController( + interval: second, + maxBackoff: const Duration(seconds: 5), + tick: () async { + calls++; + if (fail) throw StateError('down'); + }, + ); + + c.setActive(true); + await tester.pump(second); + expect(calls, 1); + expect(c.status.value.failures, 1); + expect(c.status.value.isStale, isTrue); + expect(c.nextDelay, const Duration(seconds: 2)); + + await tester.pump(const Duration(seconds: 1)); + expect(calls, 1, reason: 'still backing off'); + await tester.pump(const Duration(seconds: 1)); + expect(calls, 2); + expect(c.nextDelay, const Duration(seconds: 4)); + + await tester.pump(const Duration(seconds: 4)); + expect(calls, 3); + expect(c.nextDelay, const Duration(seconds: 5), reason: 'capped'); + + fail = false; + await tester.pump(const Duration(seconds: 5)); + expect(calls, 4); + expect(c.status.value.isStale, isFalse); + expect(c.status.value.lastSuccess, isNotNull); + expect(c.nextDelay, second); + c.dispose(); + }); + + testWidgets('stopping while a tick runs does not schedule another', + (tester) async { + var calls = 0; + final gate = Completer(); + final c = PollingController( + interval: second, + tick: () async { + calls++; + await gate.future; + }, + ); + + c.setActive(true); + await tester.pump(second); + c.setActive(false); + gate.complete(); + await tester.pump(const Duration(seconds: 10)); + expect(calls, 1); + expect(c.isActive, isFalse); + c.dispose(); + }); + + testWidgets('a result that lands after a stop is not reported', + (tester) async { + final gate = Completer(); + final c = PollingController( + interval: second, + tick: () async { + await gate.future; + throw StateError('late'); + }, + ); + + c.setActive(true); + await tester.pump(second); + c.setActive(false); + c.reset(); + gate.complete(); + await tester.pump(); + expect(c.status.value.failures, 0); + c.dispose(); + }); + + testWidgets('changing the interval reschedules', (tester) async { + var calls = 0; + final c = PollingController(interval: second, tick: () async => calls++); + + c.setActive(true); + c.setInterval(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(calls, 1); + c.dispose(); + }); + }); + + group('PollingState', () { + testWidgets('polls only under an enabled TickerMode', (tester) async { + var ticks = 0; + Widget app({required bool enabled}) => TickerMode( + enabled: enabled, + child: _Host(onTick: () => ticks++), + ); + + await tester.pumpWidget(app(enabled: true)); + await tester.pump(const Duration(milliseconds: 350)); + expect(ticks, greaterThan(0)); + + await tester.pumpWidget(app(enabled: false)); + final frozen = ticks; + await tester.pump(const Duration(seconds: 1)); + expect(ticks, frozen); + + await tester.pumpWidget(app(enabled: true)); + await tester.pump(const Duration(milliseconds: 150)); + expect(ticks, greaterThan(frozen)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('stops when the view is disposed', (tester) async { + var ticks = 0; + await tester.pumpWidget(_Host(onTick: () => ticks++)); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpWidget(const SizedBox.shrink()); + final after = ticks; + await tester.pump(const Duration(seconds: 1)); + expect(ticks, after); + }); + }); + + group('PollStaleOverlay', () { + testWidgets('shows the note only while the latest poll failed', + (tester) async { + final status = ValueNotifier(const PollStatus()); + await tester.pumpWidget(MaterialApp( + home: PollStaleOverlay(status: status, child: const SizedBox()), + )); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsNothing); + + status.value = PollStatus( + lastSuccess: DateTime(2026, 1, 1, 9, 5, 7), + lastError: 'boom', + failures: 2, + ); + await tester.pump(); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsOneWidget); + expect(find.textContaining('09:05:07'), findsOneWidget); + + status.value = PollStatus(lastSuccess: DateTime(2026, 1, 1, 9, 6)); + await tester.pump(); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsNothing); + }); + }); +} diff --git a/test/core/motion/ticker_gated_polling_test.dart b/test/core/motion/ticker_gated_polling_test.dart deleted file mode 100644 index 2028efec..00000000 --- a/test/core/motion/ticker_gated_polling_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; - -void main() { - testWidgets('cancels timer when TickerMode is disabled', (tester) async { - Timer? timer; - var ticks = 0; - - await tester.pumpWidget( - TickerMode( - enabled: true, - child: Builder( - builder: (context) { - timer = syncTickerGatedPeriodicTimer( - context: context, - timer: timer, - shouldRun: true, - interval: const Duration(milliseconds: 20), - onTick: () => ticks++, - ); - return const SizedBox.shrink(); - }, - ), - ), - ); - await tester.pump(const Duration(milliseconds: 50)); - expect(ticks, greaterThan(0)); - expect(timer, isNotNull); - - await tester.pumpWidget( - TickerMode( - enabled: false, - child: Builder( - builder: (context) { - timer = syncTickerGatedPeriodicTimer( - context: context, - timer: timer, - shouldRun: true, - interval: const Duration(milliseconds: 20), - onTick: () => ticks++, - ); - return const SizedBox.shrink(); - }, - ), - ), - ); - final afterDisable = ticks; - await tester.pump(const Duration(milliseconds: 60)); - expect(ticks, afterDisable); - expect(timer, isNull); - }); -}