Files
iperf3-gui/lib/ui/features/test_run/test_run_view_model.dart
T
mikkeli cbb2aba8b0 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>
2026-08-26 19:35:10 +09:00

173 lines
4.8 KiB
Dart

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.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}) {
_eventSubscription = iperf3Service.events.listen(_onEvent);
}
StreamSubscription<Iperf3Event>? _eventSubscription;
Timer? _repaintTimer;
bool _dirty = false;
TestRunState _state = TestRunState.idle;
TestRunState get state => _state;
Iperf3Result? _result;
Iperf3Result? get result => _result;
TestConfiguration? _currentConfig;
TestConfiguration? get currentConfig => _currentConfig;
final StringBuffer _transcript = StringBuffer();
/// 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;
/// Whether the most recent finished run ended because the user stopped it.
bool get wasStopped => _result?.stopped ?? false;
/// Start a test with the given configuration.
Future<void> runTest(TestConfiguration config) async {
if (_state == TestRunState.running) return;
if (!config.isValid) {
_resetRunData();
_transcript
.writeln('Invalid configuration: check the server address and port.');
_state = TestRunState.error;
notifyListeners();
return;
}
_resetRunData();
_state = TestRunState.running;
_currentConfig = config;
notifyListeners();
try {
final result = await iperf3Service.run(config);
_result = result;
if (_transcript.isEmpty && result.rawOutput.isNotEmpty) {
_transcript.write(result.rawOutput);
}
_state =
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
} catch (e) {
_transcript.writeln('Test failed: $e');
_state = TestRunState.error;
} finally {
_stopRepaintTimer();
notifyListeners();
}
}
/// Request that a running test stop early.
Future<void> stopTest() async {
if (_state != TestRunState.running) return;
await iperf3Service.stop();
}
/// Reset to idle state.
void reset() {
_resetRunData();
_state = TestRunState.idle;
_currentConfig = null;
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() {
_stopRepaintTimer();
_eventSubscription?.cancel();
iperf3Service.dispose();
super.dispose();
}
}