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]}';
}