cbb2aba8b0
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>
291 lines
9.9 KiB
Dart
291 lines
9.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
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.
|
|
///
|
|
/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed
|
|
/// via JNI, so there is no binary extraction or chmod step.
|
|
class TestRunScreen extends StatelessWidget {
|
|
const TestRunScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ChangeNotifierProvider(
|
|
create: (_) => TestRunViewModel(
|
|
iperf3Service: Iperf3Service(),
|
|
),
|
|
child: const _TestRunContent(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TestRunContent extends StatefulWidget {
|
|
const _TestRunContent();
|
|
|
|
@override
|
|
State<_TestRunContent> createState() => _TestRunContentState();
|
|
}
|
|
|
|
class _TestRunContentState extends State<_TestRunContent> {
|
|
static const _defaults = TestConfiguration();
|
|
|
|
final _serverController =
|
|
TextEditingController(text: _defaults.server);
|
|
final _portController =
|
|
TextEditingController(text: _defaults.port.toString());
|
|
final _durationController =
|
|
TextEditingController(text: _defaults.duration.inSeconds.toString());
|
|
final _streamsController =
|
|
TextEditingController(text: _defaults.parallelStreams.toString());
|
|
final _outputScrollController = ScrollController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_serverController.dispose();
|
|
_portController.dispose();
|
|
_durationController.dispose();
|
|
_streamsController.dispose();
|
|
_outputScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
TestConfiguration _readConfiguration() {
|
|
return TestConfiguration(
|
|
server: _serverController.text.trim(),
|
|
port: int.tryParse(_portController.text) ?? _defaults.port,
|
|
duration: Duration(
|
|
seconds: int.tryParse(_durationController.text) ??
|
|
_defaults.duration.inSeconds,
|
|
),
|
|
parallelStreams:
|
|
int.tryParse(_streamsController.text) ?? _defaults.parallelStreams,
|
|
);
|
|
}
|
|
|
|
/// Keep the newest output in view, but only once the frame that added it has
|
|
/// been laid out.
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_outputScrollController.hasClients) return;
|
|
_outputScrollController
|
|
.jumpTo(_outputScrollController.position.maxScrollExtent);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('iperf3-gui'),
|
|
actions: [
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) => IconButton(
|
|
tooltip: 'Copy output',
|
|
icon: const Icon(Icons.copy_all),
|
|
onPressed: vm.output.isEmpty
|
|
? null
|
|
: () {
|
|
Clipboard.setData(ClipboardData(text: vm.output));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Output copied')),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: _serverController,
|
|
autocorrect: false,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Server',
|
|
hintText: 'e.g. iperf.es.net or 192.168.1.1',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _portController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Port'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _durationController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Seconds'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _streamsController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Streams'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
if (vm.isRunning) {
|
|
return OutlinedButton.icon(
|
|
onPressed: vm.stopTest,
|
|
icon: const Icon(Icons.stop),
|
|
label: const Text('STOP'),
|
|
);
|
|
}
|
|
return ElevatedButton(
|
|
onPressed: () => vm.runTest(_readConfiguration()),
|
|
child: const Text('GO'),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Status line
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final (String label, Color color) = switch (vm.state) {
|
|
TestRunState.idle => ('Ready', scheme.onSurfaceVariant),
|
|
TestRunState.running => ('Running…', scheme.primary),
|
|
TestRunState.completed => (
|
|
vm.wasStopped ? 'Stopped' : 'Test complete',
|
|
vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary,
|
|
),
|
|
TestRunState.error => (
|
|
'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}',
|
|
scheme.error,
|
|
),
|
|
};
|
|
return Row(
|
|
children: [
|
|
if (vm.isRunning)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
Text(label, style: TextStyle(color: color)),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
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(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
_scrollToBottom();
|
|
return SingleChildScrollView(
|
|
controller: _outputScrollController,
|
|
child: SelectableText(
|
|
vm.output.isEmpty
|
|
? 'Output will appear here.'
|
|
: vm.output,
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// 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()]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|