feat(ui): render the event stream and show live throughput figures

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:35:10 +09:00
parent 7d8d06e2e5
commit cbb2aba8b0
5 changed files with 415 additions and 34 deletions
@@ -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 = <String>[];
if (e.targetHost != null) {
final port = e.targetPort != null ? ', port ${e.targetPort}' : '';
parts.add('Connecting to host ${e.targetHost}$port');
}
final detail = <String>[
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 = <String>['- ' * 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]}';
}
@@ -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<TestRunViewModel>(
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()]),
),
],
);
}
}
@@ -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<String>? _outputSubscription;
StreamSubscription<Iperf3Event>? _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<Iperf3Interval> _intervals = [];
/// Per-interval throughput samples, in arrival order. Structured, so a chart
/// can be driven from this without re-parsing text.
List<Iperf3Interval> 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();
}