From 29e8a19643b6c7a621c0328088f27c87e3094342 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 23:54:35 +0530 Subject: [PATCH 1/4] feat(dashboard): add compact myspeed widget with recent results and test trigger Introduce a dedicated compact dashboard widget for MySpeed instances. - Add DashboardWidgetKind.myspeed with label, icon, and serviceKinds mapping. - Implement DashboardMySpeedWidget rendering DOWN, UP, and PING metrics using MySpeedMetricBox. - Adhere strictly to dynamic theme colors across badge, metrics, and indicators. - Display dedicated timestamp pill box alongside server information. - Add compact 'Run test' button with confirmation dialog, API trigger, AnimatedSwitcher running spinner, and automatic status polling to refresh recent results upon completion. - Integrate into DashboardBoard widget builder and refresh cycle. - Add comprehensive widget tests covering metrics rendering, confirmation dialog, execution state, dynamic theme colors, and navigation. --- app/lib/src/dashboard/dashboard_board.dart | 10 + .../src/dashboard/dashboard_widget_kind.dart | 6 + .../src/dashboard/widgets/myspeed_widget.dart | 366 ++++++++++++++++++ app/test/myspeed_widget_test.dart | 281 ++++++++++++++ 4 files changed, 663 insertions(+) create mode 100644 app/lib/src/dashboard/widgets/myspeed_widget.dart create mode 100644 app/test/myspeed_widget_test.dart diff --git a/app/lib/src/dashboard/dashboard_board.dart b/app/lib/src/dashboard/dashboard_board.dart index 3d9ee06f..d44226d1 100644 --- a/app/lib/src/dashboard/dashboard_board.dart +++ b/app/lib/src/dashboard/dashboard_board.dart @@ -22,6 +22,7 @@ import 'package:service_tracearr/service_tracearr.dart'; import 'package:service_rtorrent/service_rtorrent.dart'; import 'package:service_transmission/service_transmission.dart'; import 'package:service_gluetun/service_gluetun.dart'; +import 'package:service_myspeed/service_myspeed.dart'; import '../health_providers.dart'; import '../screens/calendar_screen.dart'; @@ -30,6 +31,7 @@ import 'dashboard_widget_kind.dart'; import 'widgets/dashdot_widget.dart'; import 'widgets/downloads_widget.dart'; import 'widgets/gluetun_status_widget.dart'; +import 'widgets/myspeed_widget.dart'; import 'widgets/recently_added_widget.dart'; import 'widgets/recently_downloaded_widget.dart'; import 'widgets/requests_widget.dart'; @@ -196,6 +198,10 @@ class DashboardBoard extends ConsumerWidget { return DashboardGluetunStatusWidget( instances: _byKind(instances, ServiceKind.gluetun), ); + case DashboardWidgetKind.myspeed: + return DashboardMySpeedWidget( + instances: _byKind(instances, ServiceKind.myspeed), + ); case DashboardWidgetKind.wakeOnLan: return const DashboardWakeOnLanWidget(); } @@ -256,6 +262,10 @@ class DashboardBoard extends ConsumerWidget { case ServiceKind.gluetun: ref.invalidate(gluetunVpnStatusProvider(i)); ref.invalidate(gluetunPublicIpProvider(i)); + case ServiceKind.myspeed: + ref.invalidate(myspeedStatusProvider(i)); + ref.invalidate(myspeed24HourTestsProvider(i)); + ref.invalidate(myspeedHistoryProvider(i)); default: break; } diff --git a/app/lib/src/dashboard/dashboard_widget_kind.dart b/app/lib/src/dashboard/dashboard_widget_kind.dart index 9ca1f99f..d0eea528 100644 --- a/app/lib/src/dashboard/dashboard_widget_kind.dart +++ b/app/lib/src/dashboard/dashboard_widget_kind.dart @@ -13,6 +13,7 @@ enum DashboardWidgetKind { dashdot, speedtestResults, gluetunStatus, + myspeed, wakeOnLan, } @@ -28,6 +29,7 @@ extension DashboardWidgetKindX on DashboardWidgetKind { DashboardWidgetKind.dashdot => 'Dashdot', DashboardWidgetKind.speedtestResults => 'Speedtest results', DashboardWidgetKind.gluetunStatus => 'Gluetun VPN', + DashboardWidgetKind.myspeed => 'MySpeed', DashboardWidgetKind.wakeOnLan => 'Wake on LAN', }; @@ -42,6 +44,7 @@ extension DashboardWidgetKindX on DashboardWidgetKind { DashboardWidgetKind.dashdot => Icons.donut_large_rounded, DashboardWidgetKind.speedtestResults => Icons.speed_outlined, DashboardWidgetKind.gluetunStatus => Icons.shield_outlined, + DashboardWidgetKind.myspeed => Icons.network_check_outlined, DashboardWidgetKind.wakeOnLan => Icons.power_settings_new_rounded, }; @@ -88,6 +91,9 @@ extension DashboardWidgetKindX on DashboardWidgetKind { DashboardWidgetKind.gluetunStatus => const [ ServiceKind.gluetun ], + DashboardWidgetKind.myspeed => const [ + ServiceKind.myspeed + ], // Wake-on-LAN answers to no service. Its targets are machines on the // network, kept on the profile beside the instances, so there is no // service whose presence could stand in for being set up. diff --git a/app/lib/src/dashboard/widgets/myspeed_widget.dart b/app/lib/src/dashboard/widgets/myspeed_widget.dart new file mode 100644 index 00000000..6b671891 --- /dev/null +++ b/app/lib/src/dashboard/widgets/myspeed_widget.dart @@ -0,0 +1,366 @@ +import 'dart:async'; + +import 'package:core_models/core_models.dart'; +import 'package:core_router/core_router.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:service_myspeed/service_myspeed.dart'; + +import '../dashboard_widget_card.dart'; +import '../dashboard_widget_kind.dart'; + +/// Compact dashboard widget for MySpeed. +/// +/// Displays the most recent speedtest result (down, up, ping) and a compact +/// "Run test" button with live status tracking and confirmation dialog. +class DashboardMySpeedWidget extends ConsumerWidget { + const DashboardMySpeedWidget({ + required this.instances, + super.key, + }); + + final List instances; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final Color accent = Theme.of(context).colorScheme.primary; + return DashboardWidgetCard( + kind: DashboardWidgetKind.myspeed, + accent: accent, + onTap: instances.length == 1 + ? () => _open(context, instances.first) + : null, + child: instances.isEmpty + ? const DashboardIdleRow(text: 'No MySpeed instances configured') + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int index = 0; index < instances.length; index++) ...[ + if (index > 0) const Divider(height: Insets.lg), + _MySpeedInstanceBlock( + instance: instances[index], + showName: instances.length > 1, + onTap: instances.length > 1 + ? () => _open(context, instances[index]) + : null, + ), + ], + ], + ), + ); + } + + void _open(BuildContext context, Instance instance) { + context.go( + AtriumRoutes.servicePath(instance.kind.name, instance.id), + ); + } +} + +class _MySpeedInstanceBlock extends ConsumerStatefulWidget { + const _MySpeedInstanceBlock({ + required this.instance, + required this.showName, + required this.onTap, + }); + + final Instance instance; + final bool showName; + final VoidCallback? onTap; + + @override + ConsumerState<_MySpeedInstanceBlock> createState() => + _MySpeedInstanceBlockState(); +} + +class _MySpeedInstanceBlockState extends ConsumerState<_MySpeedInstanceBlock> { + bool _isLocallyRunning = false; + Timer? _pollTimer; + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 3), (Timer timer) async { + ref.invalidate(myspeedStatusProvider(widget.instance)); + MySpeedStatus? newStatus; + try { + newStatus = + await ref.read(myspeedStatusProvider(widget.instance).future); + } catch (_) { + newStatus = null; + } + + final bool stillRunning = newStatus?.isRunning ?? false; + if (!mounted) { + timer.cancel(); + return; + } + + if (!stillRunning) { + timer.cancel(); + setState(() => _isLocallyRunning = false); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + try { + await ref + .read(myspeedHistoryProvider(widget.instance).notifier) + .fetchDiff(); + } catch (_) {} + } + }); + } + + Future _runSpeedtest() async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + title: const Text('Run speedtest'), + content: Text('Start a new speedtest on ${widget.instance.name}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Start'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + setState(() => _isLocallyRunning = true); + + try { + final MySpeedApi api = + await ref.read(myspeedApiProvider(widget.instance).future); + await api.runSpeedtest(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Speedtest triggered successfully')), + ); + _startPolling(); + } catch (e) { + if (!mounted) return; + setState(() => _isLocallyRunning = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to trigger speedtest: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final AsyncValue statusAsync = + ref.watch(myspeedStatusProvider(widget.instance)); + final MySpeedTest? latest = + ref.watch(myspeedLatestTestProvider(widget.instance)); + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Insets.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showName) + Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: Text( + widget.instance.name, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colors.onSurfaceVariant, + ), + ), + ), + statusAsync.when( + skipLoadingOnReload: true, + skipLoadingOnRefresh: true, + loading: () => latest != null + ? _buildContent(context, statusAsync.value, latest) + : Center( + child: Padding( + padding: const EdgeInsets.all(Insets.sm), + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: colors.primary, + ), + ), + ), + ), + error: (Object error, StackTrace _) => latest != null + ? _buildContent(context, statusAsync.value, latest) + : DashboardErrorRow( + onRetry: () { + ref.invalidate(myspeedStatusProvider(widget.instance)); + ref.invalidate( + myspeed24HourTestsProvider(widget.instance), + ); + }, + ), + data: (MySpeedStatus status) => + _buildContent(context, status, latest), + ), + ], + ), + ), + ); + } + + Widget _buildContent( + BuildContext context, + MySpeedStatus? status, + MySpeedTest? latest, + ) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final bool isRunning = _isLocallyRunning || (status?.isRunning ?? false); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_downward_rounded, + label: 'DOWN', + value: latest != null + ? latest.download.toStringAsFixed(1) + : '--', + unit: 'Mbps', + iconColor: colors.primary, + boxColor: colors.primaryContainer.withValues(alpha: 0.25), + borderColor: colors.primary.withValues(alpha: 0.25), + ), + ), + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_upward_rounded, + label: 'UP', + value: + latest != null ? latest.upload.toStringAsFixed(1) : '--', + unit: 'Mbps', + iconColor: colors.tertiary, + boxColor: colors.tertiaryContainer.withValues(alpha: 0.25), + borderColor: colors.tertiary.withValues(alpha: 0.25), + ), + ), + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.timer_outlined, + label: 'PING', + value: latest != null ? latest.ping.toStringAsFixed(0) : '--', + unit: 'ms', + iconColor: colors.secondary, + boxColor: colors.secondaryContainer.withValues(alpha: 0.25), + borderColor: colors.secondary.withValues(alpha: 0.25), + ), + ), + ], + ), + const SizedBox(height: Insets.sm), + Row( + children: [ + if (latest?.createdAt != null) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.access_time_rounded, + size: 12, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + latest!.formattedDate, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: Insets.xs), + ], + Expanded( + child: Text( + latest?.server != null && latest!.server!.isNotEmpty + ? latest.server! + : (latest?.createdAt == null ? 'No results yet' : ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: Insets.sm), + FilledButton.tonal( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: Insets.md, + vertical: Insets.xs, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: isRunning ? null : _runSpeedtest, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isRunning + ? SizedBox( + key: const ValueKey('running-spinner'), + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onSecondaryContainer, + ), + ) + : const Row( + key: ValueKey('run-button-content'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.play_arrow_rounded, size: 16), + SizedBox(width: 4), + Text('Run test'), + ], + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/app/test/myspeed_widget_test.dart b/app/test/myspeed_widget_test.dart new file mode 100644 index 00000000..baf84d34 --- /dev/null +++ b/app/test/myspeed_widget_test.dart @@ -0,0 +1,281 @@ +import 'package:atrium/src/dashboard/dashboard_widget_kind.dart'; +import 'package:atrium/src/dashboard/widgets/myspeed_widget.dart'; +import 'package:core_models/core_models.dart'; +import 'package:core_router/core_router.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:service_myspeed/service_myspeed.dart'; + +void main() { + const Instance instance = Instance( + id: 'myspeed-test-1', + name: 'Home MySpeed', + kind: ServiceKind.myspeed, + localUrl: 'http://192.168.1.100:5216', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'secret'), + ); + + final MySpeedTest sampleTest = MySpeedTest( + id: '42', + download: 350.5, + upload: 85.2, + ping: 15.0, + jitter: 2.0, + server: 'Cloudflare', + createdAt: DateTime(2026, 9, 20, 10, 15), + ); + + testWidgets('renders compact MySpeed widget with down, up, and ping metrics', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + myspeedStatusProvider(instance).overrideWith( + (Ref ref) async => const MySpeedStatus(isRunning: false), + ), + myspeed24HourTestsProvider(instance).overrideWith( + (Ref ref) async => [sampleTest], + ), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const Scaffold( + body: DashboardMySpeedWidget( + instances: [instance], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('MySpeed'), findsOneWidget); + expect(find.text('DOWN'), findsOneWidget); + expect(find.text('350.5'), findsOneWidget); + expect(find.text('UP'), findsOneWidget); + expect(find.text('85.2'), findsOneWidget); + expect(find.text('PING'), findsOneWidget); + expect(find.text('15'), findsOneWidget); + expect(find.text('Run test'), findsOneWidget); + expect(find.textContaining('Cloudflare'), findsOneWidget); + expect(find.byIcon(Icons.access_time_rounded), findsOneWidget); + expect(find.text(sampleTest.formattedDate), findsOneWidget); + }); + + testWidgets('run test button displays confirmation dialog and triggers test', + (WidgetTester tester) async { + bool didRunSpeedtest = false; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + myspeedStatusProvider(instance).overrideWith( + (Ref ref) async => const MySpeedStatus(isRunning: false), + ), + myspeed24HourTestsProvider(instance).overrideWith( + (Ref ref) async => [sampleTest], + ), + myspeedApiProvider(instance).overrideWith( + (Ref ref) async => _MockMySpeedApi( + onRun: () => didRunSpeedtest = true, + ), + ), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const Scaffold( + body: DashboardMySpeedWidget( + instances: [instance], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Tap Run test button + await tester.tap(find.text('Run test')); + await tester.pumpAndSettle(); + + // Dialog appears + expect(find.text('Run speedtest'), findsOneWidget); + expect( + find.text('Start a new speedtest on Home MySpeed?'), + findsOneWidget, + ); + + // Tap Cancel + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(didRunSpeedtest, isFalse); + + // Tap Run test again + await tester.tap(find.text('Run test')); + await tester.pumpAndSettle(); + + // Tap Start + await tester.tap(find.text('Start')); + await tester.pump(); + + expect(didRunSpeedtest, isTrue); + expect(find.text('Speedtest triggered successfully'), findsOneWidget); + }); + + testWidgets('displays running spinner when speedtest is active', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + myspeedStatusProvider(instance).overrideWith( + (Ref ref) async => const MySpeedStatus(isRunning: true), + ), + myspeed24HourTestsProvider(instance).overrideWith( + (Ref ref) async => [sampleTest], + ), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const Scaffold( + body: DashboardMySpeedWidget( + instances: [instance], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Banner text is not displayed + expect(find.text('Speedtest running...'), findsNothing); + // Button shows loading spinner instead of 'Run test' text + expect(find.text('Run test'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('tapping card opens service details route', + (WidgetTester tester) async { + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const Scaffold( + body: DashboardMySpeedWidget( + instances: [instance], + ), + ), + ), + GoRoute( + path: AtriumRoutes.service, + builder: (BuildContext context, GoRouterState state) => Scaffold( + body: Text( + 'Opened ${state.pathParameters['kind']} ${state.pathParameters['instanceId']}', + ), + ), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + myspeedStatusProvider(instance).overrideWith( + (Ref ref) async => const MySpeedStatus(isRunning: false), + ), + myspeed24HourTestsProvider(instance).overrideWith( + (Ref ref) async => [sampleTest], + ), + ], + child: MaterialApp.router( + theme: AtriumTheme.light(null), + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('MySpeed')); + await tester.pumpAndSettle(); + + expect(find.text('Opened myspeed myspeed-test-1'), findsOneWidget); + }); + + testWidgets('the badge and metric box icons follow dynamic theme colors', + (WidgetTester tester) async { + final ColorScheme scheme = + ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + myspeedStatusProvider(instance).overrideWith( + (Ref ref) async => const MySpeedStatus(isRunning: false), + ), + myspeed24HourTestsProvider(instance).overrideWith( + (Ref ref) async => [sampleTest], + ), + ], + child: MaterialApp( + theme: ThemeData(colorScheme: scheme), + home: const Scaffold( + body: DashboardMySpeedWidget( + instances: [instance], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final Icon badge = tester.widget( + find.byIcon(DashboardWidgetKind.myspeed.icon), + ); + expect(badge.color, scheme.primary); + + final Icon downIcon = tester.widget( + find.byIcon(Icons.arrow_downward_rounded), + ); + expect(downIcon.color, scheme.primary); + + final Icon upIcon = tester.widget( + find.byIcon(Icons.arrow_upward_rounded), + ); + expect(upIcon.color, scheme.tertiary); + + final Icon pingIcon = tester.widget( + find.byIcon(Icons.timer_outlined), + ); + expect(pingIcon.color, scheme.secondary); + }); +} + +class _MockMySpeedApi extends MySpeedApi { + _MockMySpeedApi({required this.onRun}) : super(_FakeDio()); + + final VoidCallback onRun; + + @override + Future runSpeedtest() async { + onRun(); + return true; + } +} + +class _FakeDio implements Dio { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} From 02b3b2800cc5cc59d0c8d0a1fe4dfbf300dd0d78 Mon Sep 17 00:00:00 2001 From: retransmit Date: Mon, 21 Sep 2026 00:27:25 +0530 Subject: [PATCH 2/4] feat(service_myspeed): keep a failed run's error and offer the newest tests at any age MySpeed keeps a failed run as a row with an error and no speeds, so the model now carries that error and a helper picks the newest run that produced figures. A new provider reads the five newest tests across the whole history: the day's window is empty for a weekly schedule, and the History tab's thousand rows are far more than a card needs. --- .../lib/src/models/myspeed_test.dart | 9 +++++++ .../lib/src/myspeed_providers.dart | 23 ++++++++++++++++++ .../test/myspeed_api_test.dart | 24 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/services/service_myspeed/lib/src/models/myspeed_test.dart b/services/service_myspeed/lib/src/models/myspeed_test.dart index db2a7002..c05b42fd 100644 --- a/services/service_myspeed/lib/src/models/myspeed_test.dart +++ b/services/service_myspeed/lib/src/models/myspeed_test.dart @@ -11,6 +11,7 @@ class MySpeedTest { this.createdAt, this.server, this.duration, + this.error, this.raw, }); @@ -37,6 +38,10 @@ class MySpeedTest { /// Test execution duration in seconds or ms, if provided by API. final int? duration; + /// What went wrong, for a run that failed. MySpeed keeps such runs as + /// rows with this set and no speeds. + final String? error; + /// Raw payload from MySpeed API. final Map? raw; @@ -84,6 +89,9 @@ class MySpeedTest { } final int? durVal = json['time'] is int ? json['time'] as int : null; + final dynamic rawError = json['error']; + final String? errorVal = + rawError is String && rawError.trim().isNotEmpty ? rawError : null; return MySpeedTest( id: idVal.toString(), @@ -94,6 +102,7 @@ class MySpeedTest { createdAt: date, server: serverName, duration: durVal, + error: errorVal, raw: json, ); } diff --git a/services/service_myspeed/lib/src/myspeed_providers.dart b/services/service_myspeed/lib/src/myspeed_providers.dart index 903df792..66de723d 100644 --- a/services/service_myspeed/lib/src/myspeed_providers.dart +++ b/services/service_myspeed/lib/src/myspeed_providers.dart @@ -162,3 +162,26 @@ final myspeedStorageProvider = final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); return api.getStorage(); }); + +/// The newest few tests across the whole history, for the dashboard: the +/// latest good run and whether a newer one failed, without the day's window +/// (a weekly schedule has no run in the last 24 hours) and without the +/// History tab's thousand rows. +final myspeedRecentTestsProvider = + FutureProvider.autoDispose.family, Instance>(( + Ref ref, + Instance instance, +) async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getSpeedtests(hours: MySpeedApi.historyHours, limit: 5); +}); + +/// The newest run that produced figures, or null. A failed run is a row +/// with an error and no speeds, which is not what a card should show. +MySpeedTest? myspeedLatestGood(List? tests) { + if (tests == null) return null; + for (final MySpeedTest test in tests) { + if (test.error == null) return test; + } + return null; +} diff --git a/services/service_myspeed/test/myspeed_api_test.dart b/services/service_myspeed/test/myspeed_api_test.dart index 3db0baf8..0c67656a 100644 --- a/services/service_myspeed/test/myspeed_api_test.dart +++ b/services/service_myspeed/test/myspeed_api_test.dart @@ -61,6 +61,30 @@ void main() { expect(test.server, 'Cloudflare'); }); + test('keeps the error a failed run carries', () { + final MySpeedTest failed = MySpeedTest.fromJson({ + 'id': 7, + 'ping': null, + 'download': null, + 'upload': null, + 'error': 'Speedtest timed out', + 'created': '2026-09-20T15:00:32.908Z', + }); + final MySpeedTest good = MySpeedTest.fromJson({ + 'id': 8, + 'ping': 32, + 'download': 47.76, + 'upload': 17.21, + 'error': null, + 'created': '2026-09-20T15:30:32.908Z', + }); + + expect(failed.error, 'Speedtest timed out'); + expect(good.error, isNull); + expect(myspeedLatestGood([failed, good])?.id, '8'); + expect(myspeedLatestGood([failed]), isNull); + }); + test('parses MySpeed API schema with created timestamp and serverName', () { final test = MySpeedTest.fromJson({ 'id': 42, From ffb744898a7b7571bef4318860e1cbf6c6879c90 Mon Sep 17 00:00:00 2001 From: retransmit Date: Mon, 21 Sep 2026 00:27:25 +0530 Subject: [PATCH 3/4] fix(dashboard): read MySpeed's latest result without loading its whole history The card read the day's list and, when that was empty, the History tab's keep-alive thousand-row list; pull to refresh reloaded that list for every MySpeed instance, and a run's end fetched it too. The card now reads the newest few tests at any age, shows the latest one that produced figures, and leaves the History to the History tab. --- app/lib/src/dashboard/dashboard_board.dart | 3 +-- .../src/dashboard/widgets/myspeed_widget.dart | 17 ++++++++--------- app/test/myspeed_widget_test.dart | 10 +++++----- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/lib/src/dashboard/dashboard_board.dart b/app/lib/src/dashboard/dashboard_board.dart index d44226d1..2a3d1c57 100644 --- a/app/lib/src/dashboard/dashboard_board.dart +++ b/app/lib/src/dashboard/dashboard_board.dart @@ -264,8 +264,7 @@ class DashboardBoard extends ConsumerWidget { ref.invalidate(gluetunPublicIpProvider(i)); case ServiceKind.myspeed: ref.invalidate(myspeedStatusProvider(i)); - ref.invalidate(myspeed24HourTestsProvider(i)); - ref.invalidate(myspeedHistoryProvider(i)); + ref.invalidate(myspeedRecentTestsProvider(i)); default: break; } diff --git a/app/lib/src/dashboard/widgets/myspeed_widget.dart b/app/lib/src/dashboard/widgets/myspeed_widget.dart index 6b671891..b6260e6a 100644 --- a/app/lib/src/dashboard/widgets/myspeed_widget.dart +++ b/app/lib/src/dashboard/widgets/myspeed_widget.dart @@ -106,12 +106,7 @@ class _MySpeedInstanceBlockState extends ConsumerState<_MySpeedInstanceBlock> { if (!stillRunning) { timer.cancel(); setState(() => _isLocallyRunning = false); - ref.invalidate(myspeed24HourTestsProvider(widget.instance)); - try { - await ref - .read(myspeedHistoryProvider(widget.instance).notifier) - .fetchDiff(); - } catch (_) {} + ref.invalidate(myspeedRecentTestsProvider(widget.instance)); } }); } @@ -163,8 +158,12 @@ class _MySpeedInstanceBlockState extends ConsumerState<_MySpeedInstanceBlock> { final ColorScheme colors = theme.colorScheme; final AsyncValue statusAsync = ref.watch(myspeedStatusProvider(widget.instance)); - final MySpeedTest? latest = - ref.watch(myspeedLatestTestProvider(widget.instance)); + // The newest few tests at any age: the day's window is empty for a + // weekly schedule, and the History tab's list is far more than a card + // needs. + final MySpeedTest? latest = myspeedLatestGood( + ref.watch(myspeedRecentTestsProvider(widget.instance)).value, + ); return InkWell( borderRadius: BorderRadius.circular(12), @@ -209,7 +208,7 @@ class _MySpeedInstanceBlockState extends ConsumerState<_MySpeedInstanceBlock> { onRetry: () { ref.invalidate(myspeedStatusProvider(widget.instance)); ref.invalidate( - myspeed24HourTestsProvider(widget.instance), + myspeedRecentTestsProvider(widget.instance), ); }, ), diff --git a/app/test/myspeed_widget_test.dart b/app/test/myspeed_widget_test.dart index baf84d34..91c02cc4 100644 --- a/app/test/myspeed_widget_test.dart +++ b/app/test/myspeed_widget_test.dart @@ -40,7 +40,7 @@ void main() { myspeedStatusProvider(instance).overrideWith( (Ref ref) async => const MySpeedStatus(isRunning: false), ), - myspeed24HourTestsProvider(instance).overrideWith( + myspeedRecentTestsProvider(instance).overrideWith( (Ref ref) async => [sampleTest], ), ], @@ -80,7 +80,7 @@ void main() { myspeedStatusProvider(instance).overrideWith( (Ref ref) async => const MySpeedStatus(isRunning: false), ), - myspeed24HourTestsProvider(instance).overrideWith( + myspeedRecentTestsProvider(instance).overrideWith( (Ref ref) async => [sampleTest], ), myspeedApiProvider(instance).overrideWith( @@ -138,7 +138,7 @@ void main() { myspeedStatusProvider(instance).overrideWith( (Ref ref) async => const MySpeedStatus(isRunning: true), ), - myspeed24HourTestsProvider(instance).overrideWith( + myspeedRecentTestsProvider(instance).overrideWith( (Ref ref) async => [sampleTest], ), ], @@ -194,7 +194,7 @@ void main() { myspeedStatusProvider(instance).overrideWith( (Ref ref) async => const MySpeedStatus(isRunning: false), ), - myspeed24HourTestsProvider(instance).overrideWith( + myspeedRecentTestsProvider(instance).overrideWith( (Ref ref) async => [sampleTest], ), ], @@ -224,7 +224,7 @@ void main() { myspeedStatusProvider(instance).overrideWith( (Ref ref) async => const MySpeedStatus(isRunning: false), ), - myspeed24HourTestsProvider(instance).overrideWith( + myspeedRecentTestsProvider(instance).overrideWith( (Ref ref) async => [sampleTest], ), ], From ad2b0378a07a3509dc43d0f79ebebf5cd93dbb08 Mon Sep 17 00:00:00 2001 From: retransmit Date: Mon, 21 Sep 2026 00:27:25 +0530 Subject: [PATCH 4/4] docs: MySpeed has a dashboard widget --- README.md | 2 +- STATUS.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2d299ee7..d847b89a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ each one covers: | NZBGet | queue with reorder/priority/category, add NZB, speed limit, history | | Glances | CPU/memory/network/disk monitoring | | Speedtest Tracker | latest result, history chart, dashboard, confirmed remote test runs | -| MySpeed | status and manual runs, the day's results, history with averages, password auth | +| MySpeed | status and manual runs, the day's results, history with averages, password auth, dashboard widget | | Beszel | systems list, live metrics, per-system detail screen | | dashdot | live CPU, memory, disk, and GPU usage with a system-info tab, dashboard widget | | Gluetun | VPN status, forwarded port, reconnect, dashboard widget | diff --git a/STATUS.md b/STATUS.md index fc4387a7..1861ad70 100644 --- a/STATUS.md +++ b/STATUS.md @@ -28,7 +28,7 @@ Atrium is a **controller** app. Video playback was removed by design - **Dashboard widgets**, reorderable from Customize dashboard: Active downloads, Now streaming, Upcoming releases, Recently added, Recently downloaded, Requests (Seerr and Ombi), Glances, Dashdot, Speedtest - results, Gluetun VPN and Wake on LAN + results, MySpeed, Gluetun VPN and Wake on LAN - **Settings**: theme, biometric lock, profile import/export (SAF, live-verified), **Wake-on-LAN devices** (profile-stored, magic packets over pure Dart UDP), **custom HTTP headers** (global + per-instance, @@ -155,7 +155,9 @@ Atrium is a **controller** app. Video playback was removed by design history with averages and a search by test id, the server's config and storage figures. Password-protected instances work: the password is sent the way 1.0.9 reads it and the way newer builds prefer it. - Live-verified behind a reverse proxy with a required header as well + Live-verified behind a reverse proxy with a required header as well. A + dashboard widget (lxBlazarxl, PR #162) shows the latest figures and runs + a test from the board - **Speedtest Tracker** (live-verified): authenticated 1.1+ result history, latest metrics, combined download / upload chart, multi-instance dashboard widget, and confirmed 1.6+ remote runs with queued/running/terminal-state