From cbb2aba8b00f5079188bc3fa1c66f27b096c4738 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:35:10 +0900 Subject: [PATCH] feat(ui): render the event stream and show live throughput figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consuming JSON means the familiar iperf3 text layout has to be rebuilt, so a pure formatter turns each event back into a line — binary units for transfer totals, decimal units for bitrates, matching how iperf3 quotes each. The view model keeps the interval series and the end summary as data alongside the transcript, and a stat row shows current/total, mean, peak and sample count off that series. A chart can be driven from the same list without re-parsing text. Repaints are coalesced on a 250ms timer rather than one per event. Reports can now arrive several times a second across many streams, and each rebuild re-renders the whole transcript, so notifying per event made the cost of drawing scale with the report rate — which competes with the test itself for CPU and skews the measurement. Warm-up intervals (-O) are marked omitted by iperf3 and are kept out of the series so they cannot drag the mean down. Co-Authored-By: Claude Opus 5 --- .../test_run/iperf3_event_formatter.dart | 95 +++++++++++++++ lib/ui/features/test_run/test_run_screen.dart | 59 +++++++++ .../test_run/test_run_view_model.dart | 113 ++++++++++++++---- .../test_run/iperf3_event_formatter_test.dart | 87 ++++++++++++++ .../test_run/test_run_view_model_test.dart | 95 +++++++++++++-- 5 files changed, 415 insertions(+), 34 deletions(-) create mode 100644 lib/ui/features/test_run/iperf3_event_formatter.dart create mode 100644 test/ui/features/test_run/iperf3_event_formatter_test.dart diff --git a/lib/ui/features/test_run/iperf3_event_formatter.dart b/lib/ui/features/test_run/iperf3_event_formatter.dart new file mode 100644 index 0000000..898533f --- /dev/null +++ b/lib/ui/features/test_run/iperf3_event_formatter.dart @@ -0,0 +1,95 @@ +import 'package:iperf3gui/domain/models/iperf3_event.dart'; + +/// Renders parsed iperf3 events as the kind of line iperf3 itself would print. +/// +/// The app consumes `--json-stream`, so the familiar text layout has to be +/// rebuilt here. Keeping it in one pure function makes it testable and leaves +/// the structured events untouched for anything else that wants them. +String? formatIperf3Event(Iperf3Event event) { + return switch (event) { + Iperf3Start e => _formatStart(e), + Iperf3Interval e => _formatInterval(e), + Iperf3End e => _formatEnd(e), + Iperf3Error e => 'iperf3 error: ${e.message}', + Iperf3Stopped _ => 'Stopped by user.', + Iperf3LogLine e => e.text.isEmpty ? null : e.text, + }; +} + +String _formatStart(Iperf3Start e) { + final parts = []; + if (e.targetHost != null) { + final port = e.targetPort != null ? ', port ${e.targetPort}' : ''; + parts.add('Connecting to host ${e.targetHost}$port'); + } + final detail = [ + if (e.protocol != null) e.protocol!, + if (e.streamCount != null) + '${e.streamCount} stream${e.streamCount == 1 ? '' : 's'}', + if (e.durationSeconds != null) '${e.durationSeconds}s', + ]; + if (detail.isNotEmpty) parts.add(detail.join(' · ')); + return parts.isEmpty ? '' : parts.join('\n'); +} + +String _formatInterval(Iperf3Interval e) { + final window = '${_seconds(e.start)}-${_seconds(e.end)} sec'; + final line = StringBuffer('[SUM] $window ' + '${formatBytes(e.bytes)} ${formatBitrate(e.bitsPerSecond)}'); + if (e.retransmits != null) { + line.write(' ${e.retransmits} retr'); + } + if (e.omitted) line.write(' (omitted)'); + return line.toString(); +} + +String _formatEnd(Iperf3End e) { + final lines = ['- ' * 20]; + final sent = e.sent; + final received = e.received; + if (sent != null) { + lines.add('[SUM] 0.00-${_seconds(sent.seconds)} sec ' + '${formatBytes(sent.bytes)} ${formatBitrate(sent.bitsPerSecond)}' + '${sent.retransmits != null ? ' ${sent.retransmits} retr' : ''}' + ' sender'); + } + if (received != null) { + lines.add('[SUM] 0.00-${_seconds(received.seconds)} sec ' + '${formatBytes(received.bytes)} ' + '${formatBitrate(received.bitsPerSecond)} receiver'); + } + if (e.hostCpuPercent != null) { + lines.add('CPU: local ${e.hostCpuPercent!.toStringAsFixed(1)}%' + '${e.remoteCpuPercent != null ? ', remote ${e.remoteCpuPercent!.toStringAsFixed(1)}%' : ''}'); + } + return lines.join('\n'); +} + +String _seconds(double value) => value.toStringAsFixed(2); + +/// Byte totals the way iperf3 prints them — `93.8 MBytes`. +String formatBytes(int bytes) { + const units = ['Bytes', 'KBytes', 'MBytes', 'GBytes', 'TBytes']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2); + return '${value.toStringAsFixed(precision)} ${units[unit]}'; +} + +/// Bitrates the way iperf3 prints them — `786 Mbits/sec`. Decimal units, since +/// network rates are powers of ten while transfer totals are powers of two. +String formatBitrate(double bitsPerSecond) { + const units = ['bits/sec', 'Kbits/sec', 'Mbits/sec', 'Gbits/sec']; + var value = bitsPerSecond; + var unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit++; + } + final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2); + return '${value.toStringAsFixed(precision)} ${units[unit]}'; +} diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index fb2a85d..16aa122 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. @@ -193,6 +194,35 @@ class _TestRunContentState extends State<_TestRunContent> { ), const SizedBox(height: 12), + // Live throughput figures, derived from the parsed interval series + Consumer( + builder: (context, vm, _) { + final intervals = vm.intervals; + if (intervals.isEmpty) return const SizedBox.shrink(); + final rates = + intervals.map((i) => i.bitsPerSecond).toList(growable: false); + final latest = rates.last; + final peak = rates.reduce((a, b) => a > b ? a : b); + final mean = rates.reduce((a, b) => a + b) / rates.length; + final summary = vm.summary?.sent; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Stat( + label: summary != null ? 'total' : 'now', + value: formatBitrate(summary?.bitsPerSecond ?? latest), + ), + _Stat(label: 'avg', value: formatBitrate(mean)), + _Stat(label: 'peak', value: formatBitrate(peak)), + _Stat(label: 'samples', value: '${rates.length}'), + ], + ), + ); + }, + ), + // Live output Expanded( child: Container( @@ -229,3 +259,32 @@ class _TestRunContentState extends State<_TestRunContent> { ); } } + + +/// One figure in the throughput row. +class _Stat extends StatelessWidget { + const _Stat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + Text( + value, + style: theme.textTheme.titleSmall + ?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]), + ), + ], + ); + } +} diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index ec267ec..965cb14 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -2,24 +2,33 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; /// State of the test run. enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. /// -/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the -/// test runs rather than appearing all at once at the end. +/// Subscribes to [Iperf3Service.events] so the transcript and the interval +/// series both grow while the test runs, rather than appearing at the end. class TestRunViewModel extends ChangeNotifier { final Iperf3Service iperf3Service; + /// How often to notify listeners while output is arriving. iperf3 can report + /// several times a second across many streams, and each rebuild re-renders + /// the whole transcript — so repaints are coalesced instead of one per event. + static const Duration displayInterval = Duration(milliseconds: 250); + TestRunViewModel({required this.iperf3Service}) { - _outputSubscription = iperf3Service.outputLines.listen(_onOutputLine); + _eventSubscription = iperf3Service.events.listen(_onEvent); } - StreamSubscription? _outputSubscription; + StreamSubscription? _eventSubscription; + Timer? _repaintTimer; + bool _dirty = false; TestRunState _state = TestRunState.idle; TestRunState get state => _state; @@ -30,11 +39,26 @@ class TestRunViewModel extends ChangeNotifier { TestConfiguration? _currentConfig; TestConfiguration? get currentConfig => _currentConfig; - final StringBuffer _liveOutput = StringBuffer(); + final StringBuffer _transcript = StringBuffer(); - /// Output accumulated so far. Grows line by line during a run, and holds the - /// finished transcript once the test completes. - String get output => _liveOutput.toString(); + /// Human-readable output accumulated so far. + String get output => _transcript.toString(); + + final List _intervals = []; + + /// Per-interval throughput samples, in arrival order. Structured, so a chart + /// can be driven from this without re-parsing text. + List get intervals => List.unmodifiable(_intervals); + + Iperf3Start? _start; + + /// Connection details reported at the start of the run. + Iperf3Start? get start => _start; + + Iperf3End? _summary; + + /// Totals reported at the end of the run. + Iperf3End? get summary => _summary; /// Whether a test is currently in flight. bool get isRunning => _state == TestRunState.running; @@ -47,35 +71,32 @@ class TestRunViewModel extends ChangeNotifier { if (_state == TestRunState.running) return; if (!config.isValid) { - _liveOutput - ..clear() - ..writeln('Invalid configuration: check the server address and port.'); + _resetRunData(); + _transcript + .writeln('Invalid configuration: check the server address and port.'); _state = TestRunState.error; - _result = null; notifyListeners(); return; } + _resetRunData(); _state = TestRunState.running; _currentConfig = config; - _result = null; - _liveOutput.clear(); notifyListeners(); try { final result = await iperf3Service.run(config); _result = result; - // Prefer the streamed transcript — it is what the user watched build up. - // Fall back to the service's copy if nothing streamed through. - if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) { - _liveOutput.write(result.rawOutput); + if (_transcript.isEmpty && result.rawOutput.isNotEmpty) { + _transcript.write(result.rawOutput); } _state = result.exitCode == 0 ? TestRunState.completed : TestRunState.error; } catch (e) { - _liveOutput.writeln('Test failed: $e'); + _transcript.writeln('Test failed: $e'); _state = TestRunState.error; } finally { + _stopRepaintTimer(); notifyListeners(); } } @@ -88,21 +109,63 @@ class TestRunViewModel extends ChangeNotifier { /// Reset to idle state. void reset() { + _resetRunData(); _state = TestRunState.idle; - _result = null; _currentConfig = null; - _liveOutput.clear(); notifyListeners(); } - void _onOutputLine(String line) { - _liveOutput.write(line.endsWith('\n') ? line : '$line\n'); - notifyListeners(); + void _resetRunData() { + _result = null; + _transcript.clear(); + _intervals.clear(); + _start = null; + _summary = null; + _dirty = false; + } + + void _onEvent(Iperf3Event event) { + switch (event) { + case Iperf3Start e: + _start = e; + case Iperf3Interval e: + // Omitted intervals are warm-up (-O) and are not part of the series. + if (!e.omitted) _intervals.add(e); + case Iperf3End e: + _summary = e; + case Iperf3Error _: + case Iperf3Stopped _: + case Iperf3LogLine _: + break; + } + + final line = formatIperf3Event(event); + if (line != null && line.isNotEmpty) { + _transcript.writeln(line); + } + _scheduleRepaint(); + } + + /// Coalesce repaints: mark dirty and let a timer flush at [displayInterval]. + void _scheduleRepaint() { + _dirty = true; + _repaintTimer ??= Timer.periodic(displayInterval, (_) { + if (!_dirty) return; + _dirty = false; + notifyListeners(); + }); + } + + void _stopRepaintTimer() { + _repaintTimer?.cancel(); + _repaintTimer = null; + _dirty = false; } @override void dispose() { - _outputSubscription?.cancel(); + _stopRepaintTimer(); + _eventSubscription?.cancel(); iperf3Service.dispose(); super.dispose(); } diff --git a/test/ui/features/test_run/iperf3_event_formatter_test.dart b/test/ui/features/test_run/iperf3_event_formatter_test.dart new file mode 100644 index 0000000..f5abc90 --- /dev/null +++ b/test/ui/features/test_run/iperf3_event_formatter_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; + +void main() { + group('formatBytes', () { + test('scales in binary units the way iperf3 prints totals', () { + expect(formatBytes(0), equals('0.00 Bytes')); + expect(formatBytes(1024), equals('1.00 KBytes')); + expect(formatBytes(98566144), equals('94.0 MBytes')); + expect(formatBytes(1073741824), equals('1.00 GBytes')); + }); + }); + + group('formatBitrate', () { + test('scales in decimal units, as network rates are quoted', () { + expect(formatBitrate(0), equals('0.00 bits/sec')); + expect(formatBitrate(8000), equals('8.00 Kbits/sec')); + expect(formatBitrate(790000000), equals('790 Mbits/sec')); + expect(formatBitrate(2500000000), equals('2.50 Gbits/sec')); + }); + }); + + group('formatIperf3Event', () { + test('renders a start event', () { + const event = Iperf3Start( + targetHost: '192.168.2.182', + targetPort: 5201, + protocol: 'TCP', + streamCount: 1, + durationSeconds: 10, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('Connecting to host 192.168.2.182, port 5201')); + expect(text, contains('TCP')); + expect(text, contains('1 stream')); + }); + + test('renders an interval with retransmits', () { + const event = Iperf3Interval( + start: 0, + end: 1, + bytes: 98566144, + bitsPerSecond: 788410352, + retransmits: 3, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('0.00-1.00 sec')); + expect(text, contains('94.0 MBytes')); + expect(text, contains('788 Mbits/sec')); + expect(text, contains('3 retr')); + }); + + test('marks an omitted interval', () { + const event = Iperf3Interval( + start: 0, end: 1, bytes: 1, bitsPerSecond: 8, omitted: true); + expect(formatIperf3Event(event), contains('omitted')); + }); + + test('renders sender and receiver lines from an end event', () { + const event = Iperf3End( + sent: Iperf3Total( + seconds: 10, bytes: 987654321, bitsPerSecond: 790000000, + retransmits: 7), + received: Iperf3Total( + seconds: 10.01, bytes: 987000000, bitsPerSecond: 789000000), + hostCpuPercent: 12.5, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('sender')); + expect(text, contains('receiver')); + expect(text, contains('7 retr')); + expect(text, contains('CPU: local 12.5%')); + }); + + test('renders errors and stops', () { + expect(formatIperf3Event(const Iperf3Error('boom')), + equals('iperf3 error: boom')); + expect(formatIperf3Event(const Iperf3Stopped()), + equals('Stopped by user.')); + }); + + test('drops an empty log line', () { + expect(formatIperf3Event(const Iperf3LogLine('')), isNull); + }); + }); +} diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 6f83d36..025019c 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; @@ -56,11 +57,16 @@ void main() { expect(vm.result!.exitCode, equals(1)); }); - test('streamed lines accumulate into output during the run', () async { + test('streamed events accumulate into output during the run', () async { final mock = _MockIperf3Service(); mock.onRun = (_) async { - mock.emit('Connecting to host 192.168.1.1\n'); - mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n'); + mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201)); + mock.emit(const Iperf3Interval( + start: 0, + end: 1, + bytes: 11744051, + bitsPerSecond: 94100000, + )); // Let the broadcast stream deliver before the run resolves. await Future.delayed(Duration.zero); return const Iperf3Result(rawOutput: 'ignored', exitCode: 0); @@ -69,12 +75,83 @@ void main() { final vm = TestRunViewModel(iperf3Service: mock); await vm.runTest(const TestConfiguration(server: '192.168.1.1')); - expect(vm.output, contains('Connecting to host')); + expect(vm.output, contains('Connecting to host 192.168.1.1')); expect(vm.output, contains('94.1 Mbits/sec')); // The streamed transcript wins over the service's own copy. expect(vm.output, isNot(contains('ignored'))); }); + test('intervals are collected as structured samples', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); + mock.emit(const Iperf3Interval( + start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.intervals, hasLength(2)); + expect(vm.intervals[1].bitsPerSecond, equals(16000)); + }); + + test('omitted warm-up intervals are excluded from the series', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true)); + mock.emit(const Iperf3Interval( + start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.intervals, hasLength(1)); + expect(vm.intervals.single.bitsPerSecond, equals(16000)); + }); + + test('end event is retained as the summary', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3End( + sent: Iperf3Total( + seconds: 10, bytes: 987654321, bitsPerSecond: 790000000), + )); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.summary?.sent?.bitsPerSecond, equals(790000000)); + expect(vm.output, contains('790 Mbits/sec')); + }); + + test('a new run clears the previous interval series', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + expect(vm.intervals, hasLength(1)); + + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + expect(vm.intervals, hasLength(1), reason: 'not appended to the old run'); + }); + test('falls back to result output when nothing streamed', () async { final mock = _MockIperf3Service( onRun: (_) async => @@ -166,19 +243,19 @@ class _MockIperf3Service implements Iperf3Service { Future Function(TestConfiguration)? onRun; - final StreamController _controller = - StreamController.broadcast(); + final StreamController _controller = + StreamController.broadcast(); int runCallCount = 0; int stopCallCount = 0; bool disposed = false; bool _isRunning = false; - /// Push a line as if the native layer had emitted it. - void emit(String line) => _controller.add(line); + /// Push an event as if the native layer had reported it. + void emit(Iperf3Event event) => _controller.add(event); @override - Stream get outputLines => _controller.stream; + Stream get events => _controller.stream; @override bool get isRunning => _isRunning;