import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../core/app_controller.dart'; import '../core/failure.dart'; import '../l10n/strings.dart'; import '../models/server_profile.dart'; import '../services/net/icmp_probe.dart'; import '../services/net/measure.dart'; import '../services/net/tcp_ping.dart'; import '../services/net/trace_model.dart'; import '../services/ssh_service.dart'; import 'adaptive.dart'; import 'export_sheet.dart'; import 'net_widgets.dart'; import 'theme.dart'; enum DiagnosticTest { tcp, ping, traceroute } /// Network operations used by the page; replaceable in tests. class NetTools { final Lookup lookup; final PingRun Function(InternetAddress, TcpPingSettings) tcp; final IcmpRun Function(InternetAddress, PingSettings) ping; final IcmpRun Function(InternetAddress, TraceSettings) traceroute; final Future Function(InternetAddress) reverse; final String? Function(TraceMethod) unsupported; NetTools({ Lookup? lookup, PingRun Function(InternetAddress, TcpPingSettings)? tcp, IcmpRun Function(InternetAddress, PingSettings)? ping, IcmpRun Function(InternetAddress, TraceSettings)? traceroute, Future Function(InternetAddress)? reverse, String? Function(TraceMethod)? unsupported, }) : lookup = lookup ?? systemLookup, tcp = tcp ?? tcpPing, ping = ping ?? IcmpProbe.ping, traceroute = traceroute ?? IcmpProbe.traceroute, reverse = reverse ?? _reverse, unsupported = unsupported ?? IcmpProbe.unsupported; static Future _reverse(InternetAddress a) async { try { final r = await a.reverse().timeout(const Duration(seconds: 5)); return r.host == a.address ? null : r.host; } catch (_) { return null; } } } /// A ping (TCP or ICMP) of one IP version. class _PingLane { final Series series; Future Function()? cancel; StreamSubscription? subscription; String? error, end; _PingLane(Family family, InternetAddress address) : series = Series(family, address); bool get running => cancel != null; } /// A traceroute of one IP version. class _TraceState { final TraceLane lane; IcmpRun? run; StreamSubscription? subscription; String? error, end; Map? previous; _TraceState(this.lane); bool get running => run != null; } const _presets = ['4', '10', '20', '50', '100', 'custom', 'continuous']; class DiagnosticsPage extends StatefulWidget { final AppController controller; final NetTools tools; DiagnosticsPage(this.controller, {NetTools? tools, super.key}) : tools = tools ?? NetTools(); @override State createState() => _DiagnosticsPageState(); } class _DiagnosticsPageState extends State { late final host = TextEditingController( text: widget.controller.ssh.profile?.host, ); late final port = TextEditingController( text: '${widget.controller.ssh.profile?.port ?? 22}', ); final customCount = TextEditingController(text: '30'); final interval = TextEditingController(text: '1'); final timeout = TextEditingController(text: '2'); final size = TextEditingController(text: '56'); final ttl = TextEditingController(); final maxHops = TextEditingController(text: '30'); final probes = TextEditingController(text: '3'); final firstTtl = TextEditingController(text: '1'); final probeInterval = TextEditingController(text: '0'); final tracePort = TextEditingController(); final repetitions = TextEditingController(text: '1'); DiagnosticTest test = DiagnosticTest.tcp; IpMode mode = IpMode.auto; String preset = '4'; bool dontFragment = false, reverseDns = true; TraceMethod method = TraceMethod.icmp; // Resolution and the chosen address per IP version. Resolution? resolution; final chosen = {}; String? resolutionNote; // Results. final pingLanes = <_PingLane>[]; final traceStates = <_TraceState>[]; final names = {}; final _lookups = {}; // Snapshots of the last finished traceroute per IP version. final _lastTrace = >{}; MeasureKind? resultKind; TraceMethod resultMethod = TraceMethod.icmp; Map resultSettings = const {}; String resultHost = ''; IpMode resultMode = IpMode.auto; String? error; bool busy = false, compare = false; int _repeatLeft = 0; Timer? _ticker; bool get running => pingLanes.any((l) => l.running) || traceStates.any((t) => t.running); @override void dispose() { _ticker?.cancel(); _stopAll(); for (final c in [ host, port, customCount, interval, timeout, size, ttl, maxHops, probes, firstTtl, probeInterval, tracePort, repetitions, ]) { c.dispose(); } super.dispose(); } void _stopAll() { for (final l in pingLanes) { final cancel = l.cancel, sub = l.subscription; l.cancel = null; l.subscription = null; unawaited(cancel?.call()); unawaited(sub?.cancel()); } for (final t in traceStates) { final run = t.run, sub = t.subscription; t.run = null; t.subscription = null; unawaited(run?.cancel()); unawaited(sub?.cancel()); } } void stop() { for (final l in pingLanes) { if (l.running) l.end = 'stoppedByUser'; } for (final t in traceStates) { if (t.running) t.end = 'stoppedByUser'; } _repeatLeft = 0; _stopAll(); _ticker?.cancel(); setState(() => busy = false); } double _number(TextEditingController c, double min, double max) { final v = double.tryParse(c.text.trim().replaceAll(',', '.')); if (v == null || v < min || v > max) throw const Failure('invalidSetting'); return v; } int _int(TextEditingController c, int min, int max) { final v = int.tryParse(c.text.trim()); if (v == null || v < min || v > max) throw const Failure('invalidSetting'); return v; } Duration _seconds(double v) => Duration(microseconds: (v * 1e6).round()); int get _count => preset == 'continuous' ? 0 : preset == 'custom' ? _int(customCount, 1, 100000) : int.parse(preset); String _hostText() { final target = host.text.trim(); normalizeHost(target); return target; } String _code(Object e) { if (e is Failure) return e.code; if (e is SocketException) return 'dnsFailed'; return failureCode(e); } /// Looks up the addresses so a specific one can be chosen. Future findAddresses() async { FocusManager.instance.primaryFocus?.unfocus(); setState(() { busy = true; error = null; }); try { final r = await resolveTarget( _hostText(), mode == IpMode.auto ? IpMode.dual : mode, lookup: widget.tools.lookup, ); if (!mounted) return; setState(() { resolution = r; chosen ..clear() ..addAll({ for (final f in r.families) if (f.available) f.family: f.addresses.first, }); }); } catch (e) { if (mounted) setState(() => error = _code(e)); } finally { if (mounted) setState(() => busy = false); } } /// The addresses to measure: one per IP version for the selected mode, /// with the reason when a version has no address. Future> _targets( String target, ) async { final r = await resolveTarget(target, mode, lookup: widget.tools.lookup); InternetAddress? pinned(Family f, List list) { final a = resolution?.host == target ? chosen[f] : null; return a != null && list.contains(a) ? a : null; } if (mode == IpMode.auto) { final p = r.preferred!; final family = Family.of(p); return [(family, pinned(family, r.of(family)!.addresses) ?? p, null)]; } return [ for (final f in r.families) ( f.family, f.available ? pinned(f.family, f.addresses) ?? f.addresses.first : null, f.missing, ), ]; } Future run() async { FocusManager.instance.primaryFocus?.unfocus(); _stopAll(); setState(() { busy = true; error = null; pingLanes.clear(); for (final t in traceStates) { if (t.lane.hops.isNotEmpty) { _lastTrace[t.lane.family] = t.lane.snapshot(); } } traceStates.clear(); }); try { final target = _hostText(); late final Map settings; late final void Function(Family, InternetAddress) start; switch (test) { case DiagnosticTest.tcp: final s = TcpPingSettings( port: _int(port, 1, 65535), count: _count, interval: _seconds(_number(interval, 0, 3600)), timeout: _seconds(_number(timeout, .1, 60)), ); settings = s.toJson(); start = (f, a) => _startTcp(f, a, s); case DiagnosticTest.ping: final s = PingSettings( count: _count, interval: _seconds(_number(interval, .2, 3600)), timeout: _seconds(_number(timeout, .1, 60)), size: _int(size, 0, 65000), ttl: ttl.text.trim().isEmpty ? null : _int(ttl, 1, 255), dontFragment: dontFragment, ); settings = s.toJson(); start = (f, a) => _startPing(f, a, s); case DiagnosticTest.traceroute: final hops = _int(maxHops, 1, 64); final s = TraceSettings( maxHops: hops, probes: _int(probes, 1, 10), timeout: _seconds(_number(timeout, .1, 30)), firstTtl: _int(firstTtl, 1, hops), interval: _seconds(_number(probeInterval, 0, 60)), method: method, port: method == TraceMethod.icmp || tracePort.text.trim().isEmpty ? null : _int(tracePort, 1, 65535), ); // A method the platform cannot do is never replaced by another. final unsupported = widget.tools.unsupported(method); if (unsupported != null) throw Failure(unsupported); _repeatLeft = _int(repetitions, 1, 1000) - 1; settings = { ...s.toJson(), 'repetitions': _repeatLeft + 1, 'reverseDns': reverseDns, }; start = (f, a) => _startTrace(f, a, s); } final targets = await _targets(target); if (!mounted || !widget.controller.vault.unlocked) return; setState(() { resultKind = switch (test) { DiagnosticTest.tcp => MeasureKind.tcp, DiagnosticTest.ping => MeasureKind.icmp, DiagnosticTest.traceroute => MeasureKind.trace, }; resultMethod = method; resultSettings = settings; resultHost = target; resultMode = mode; final missing = [ for (final (f, a, why) in targets) if (a == null) '${f.label}: ${why ?? 'noAddressFamily'}', ]; resolutionNote = missing.isEmpty ? null : missing.join('\n'); for (final (f, a, _) in targets) { if (a != null) start(f, a); } if (!running) busy = false; }); _ticker?.cancel(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted && running) setState(() {}); }); } catch (e) { if (mounted) { setState(() { error = _code(e); busy = false; }); } } } void _laneDone() { if (!mounted) return; setState(() { if (!running) { busy = false; _ticker?.cancel(); } }); } void _startTcp(Family f, InternetAddress a, TcpPingSettings s) { final lane = _PingLane(f, a); final run = widget.tools.tcp(a, s); lane.cancel = run.cancel; lane.subscription = run.samples.listen( (sample) { if (mounted) setState(() => lane.series.add(sample)); }, onDone: () { lane.cancel = null; lane.end ??= 'statusFinished'; _laneDone(); }, ); pingLanes.add(lane); } void _startPing(Family f, InternetAddress a, PingSettings s) { final lane = _PingLane(f, a); final run = widget.tools.ping(a, s); lane.cancel = run.cancel; lane.subscription = run.events.listen( (e) { if (mounted) setState(() => lane.series.add(_pingSample(e, f, a))); }, onError: (Object e) { if (mounted) { setState(() => lane.error = e is IcmpUnavailable ? e.code : _code(e)); } }, onDone: () { lane.cancel = null; if (lane.error == null) lane.end ??= 'statusFinished'; _laneDone(); }, ); pingLanes.add(lane); } static Sample _pingSample(ProbeEvent e, Family f, InternetAddress a) { final detail = [ if (e.from != null && e.from != a) e.from!.address, if (e.icmpCode != null && e.status != ProbeStatus.reply) 'code ${e.icmpCode}', ?e.note, ].join(', '); return Sample( seq: e.seq, time: e.time ?? DateTime.now(), family: f, address: a.address, rttMs: e.status == ProbeStatus.reply ? e.rttMs : null, outcome: switch (e.status) { ProbeStatus.reply => Outcome.ok, ProbeStatus.timeout => Outcome.timeout, ProbeStatus.timeExceeded => Outcome.ttlExceeded, ProbeStatus.unreachable => Outcome.unreachable, ProbeStatus.sendFailed => Outcome.error, }, detail: detail.isEmpty ? null : detail, ); } void _startTrace(Family f, InternetAddress a, TraceSettings s) { final state = _TraceState(TraceLane(f, a))..previous = _lastTrace[f]; traceStates.add(state); _traceCycle(state, s); } void _traceCycle(_TraceState state, TraceSettings s) { state.lane.nextCycle(); final run = widget.tools.traceroute(state.lane.target, s); state.run = run; state.end = null; state.subscription = run.events.listen( (e) { if (!mounted) return; setState(() => state.lane.add(e)); if (reverseDns && e.from != null) _resolveName(e.from!); }, onError: (Object e) { if (mounted) { setState( () => state.error = e is IcmpUnavailable ? e.code : _code(e), ); } }, onDone: () { if (state.run == null) return; // stopped state.run = null; if (state.error == null) { state.end = state.lane.reached ? 'traceReached' : state.lane.hops.values.any((h) => h.markers.isNotEmpty) ? 'traceUnreachable' : state.lane.hops.isEmpty ? null : 'traceLimit'; } // Repetitions start when every IP version finished its run. if (_repeatLeft > 0 && mounted && traceStates.every((t) => !t.running)) { _repeatLeft--; for (final t in traceStates) { if (t.error == null) _traceCycle(t, s); } } _laneDone(); }, ); } Future _resolveName(InternetAddress a) async { if (!_lookups.add(a.address)) return; final name = await widget.tools.reverse(a); if (name != null && mounted) setState(() => names[a.address] = name); } void clearResults() { _stopAll(); _ticker?.cancel(); setState(() { pingLanes.clear(); traceStates.clear(); resultKind = null; error = null; resolutionNote = null; busy = false; }); } String _report(ReportFormat format) => resultKind == MeasureKind.trace ? traceReport( format, host: resultHost, mode: resultMode, settings: resultSettings, lanes: [for (final t in traceStates) t.lane], names: names, ) : pingReport( format, kind: resultKind!, host: resultHost, mode: resultMode, settings: resultSettings, series: [for (final l in pingLanes) l.series], ); Future copyResults() async { await Clipboard.setData(ClipboardData(text: _report(ReportFormat.txt))); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.tr('copied')))); } } // --------------------------------------------------------------------------- // Settings Widget _field( TextEditingController c, String label, { bool decimal = true, String? hint, }) => TextField( controller: c, enabled: !running, keyboardType: TextInputType.numberWithOptions(decimal: decimal), decoration: InputDecoration(labelText: context.tr(label), hintText: hint), ); Widget _settings(BuildContext context) { final trace = test == DiagnosticTest.traceroute; final ping = test == DiagnosticTest.ping; final locked = running || busy; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: DiagnosticTest.tcp, label: Text(context.tr('tcpPing')), ), ButtonSegment( value: DiagnosticTest.ping, label: Text(context.tr('icmpPing')), ), ButtonSegment( value: DiagnosticTest.traceroute, label: Text(context.tr('traceroute')), ), ], selected: {test}, onSelectionChanged: locked ? null : (v) => setState(() => test = v.first), ), const SizedBox(height: 8), Text( context.tr(switch (test) { DiagnosticTest.tcp => 'tcpNote', DiagnosticTest.ping => 'icmpPingNote', DiagnosticTest.traceroute => 'traceNote', }), style: mutedStyle(context), ), const SizedBox(height: 12), TextField( controller: host, enabled: !running, autocorrect: false, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: context.tr('host')), onChanged: (_) { if (resolution != null) setState(() => resolution = null); }, ), if (test == DiagnosticTest.tcp) ...[ const SizedBox(height: 12), _field(port, 'tcpPort', decimal: false), ], const SizedBox(height: 16), Text(context.tr('addressFamily'), style: mutedStyle(context)), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: IpMode.auto, label: Text(context.tr('automatic')), ), const ButtonSegment(value: IpMode.v4, label: Text('IPv4')), const ButtonSegment(value: IpMode.v6, label: Text('IPv6')), const ButtonSegment(value: IpMode.dual, label: Text('4 + 6')), ], selected: {mode}, onSelectionChanged: locked ? null : (v) => setState(() => mode = v.first), ), const SizedBox(height: 4), _addressPicker(context), if (!trace) ...[ const SizedBox(height: 8), DropdownButtonFormField( initialValue: preset, isExpanded: true, decoration: InputDecoration( labelText: context.tr(ping ? 'packetCount' : 'attemptCount'), ), items: [ for (final p in _presets) DropdownMenuItem( value: p, child: Text( p == 'custom' ? context.tr('customValue') : p == 'continuous' ? context.tr('continuous') : p, ), ), ], onChanged: locked ? null : (v) => setState(() => preset = v ?? '4'), ), if (preset == 'custom') ...[ const SizedBox(height: 12), _field(customCount, 'customCount', decimal: false), ], ], if (trace) ...[ const SizedBox(height: 12), Text(context.tr('traceMethod'), style: mutedStyle(context)), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, segments: [ for (final m in TraceMethod.values) ButtonSegment( value: m, enabled: widget.tools.unsupported(m) == null, label: Text(m.name.toUpperCase()), ), ], selected: {method}, onSelectionChanged: locked ? null : (v) => setState(() => method = v.first), ), for (final m in TraceMethod.values) if (widget.tools.unsupported(m) case final why?) Padding( padding: const EdgeInsets.only(top: 6), child: Text( '${m.name.toUpperCase()}: ${context.tr(why)}', style: mutedStyle(context), ), ), ], ExpansionTile( tilePadding: EdgeInsets.zero, title: Text(context.tr('testOptions')), childrenPadding: const EdgeInsets.only(bottom: 8), children: [ AdaptiveGrid( minTileWidth: 150, children: [ if (!trace) _field(interval, 'intervalSeconds'), _field(timeout, 'timeoutSeconds'), if (ping) ...[ _field(size, 'payloadBytes', decimal: false), _field( ttl, 'ttlHopLimit', decimal: false, hint: context.tr('systemDefault'), ), ], if (trace) ...[ _field(maxHops, 'maxHops', decimal: false), _field(probes, 'probesPerHop', decimal: false), _field(firstTtl, 'firstTtl', decimal: false), _field(probeInterval, 'probeInterval'), if (method != TraceMethod.icmp) _field( tracePort, 'tracePort', decimal: false, hint: method == TraceMethod.tcp ? '80' : '${TraceSettings.defaultUdpPort}', ), _field(repetitions, 'repetitions', decimal: false), ], ], ), if (ping) SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(context.tr('dontFragment')), value: dontFragment, onChanged: locked ? null : (v) => setState(() => dontFragment = v), ), if (trace) SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(context.tr('reverseDns')), value: reverseDns, onChanged: locked ? null : (v) => setState(() => reverseDns = v), ), ], ), const SizedBox(height: 8), if (running) FilledButton.tonalIcon( onPressed: stop, icon: const Icon(Icons.stop), label: Text(context.tr('stop')), ) else FilledButton.icon( onPressed: busy ? null : run, icon: busy ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.play_arrow), label: Text(context.tr('runDiagnostics')), ), ], ), ), ); } Widget _addressPicker(BuildContext context) { final r = resolution; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: AlignmentDirectional.centerStart, child: TextButton.icon( onPressed: running || busy ? null : findAddresses, icon: const Icon(Icons.dns_outlined, size: 18), label: Text(context.tr('resolveAddresses')), ), ), if (r != null) for (final f in r.families) if (f.available) Padding( padding: const EdgeInsets.only(bottom: 6), child: Wrap( spacing: 6, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(f.family.label, style: mutedStyle(context)), for (final a in f.addresses) ChoiceChip( label: Text(a.address), selected: chosen[f.family] == a, onSelected: running ? null : (_) => setState(() => chosen[f.family] = a), ), ], ), ) else Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( '${f.family.label}: ${context.tr(f.missing ?? 'noAddressFamily')}', style: mutedStyle(context), ), ), ], ); } // --------------------------------------------------------------------------- // Results Widget _actions(BuildContext context) => Wrap( spacing: 8, runSpacing: 4, children: [ TextButton.icon( onPressed: copyResults, icon: const Icon(Icons.copy, size: 18), label: Text(context.tr('copyResults')), ), TextButton.icon( onPressed: () => showExportSheet( context, widget.controller, baseName: 'sm-${resultKind!.name}-' '${resultHost.replaceAll(RegExp(r'[^A-Za-z0-9.-]'), '_')}', build: _report, ), icon: const Icon(Icons.ios_share, size: 18), label: Text(context.tr('exportResults')), ), TextButton.icon( onPressed: running ? null : clearResults, icon: const Icon(Icons.delete_outline, size: 18), label: Text(context.tr('clearResults')), ), ], ); String _status(String? end, bool running, String? error) => running ? context.tr('statusRunning') : error != null ? context.tr(error) : end != null ? context.tr(end) : '—'; List<(String, String)> get _missing => [ if (resolutionNote != null) for (final line in resolutionNote!.split('\n')) (line.split(': ').first, line.split(': ').last), ]; Widget _pingResults(BuildContext context) { final icmp = resultKind == MeasureKind.icmp; final tcpPort = resultSettings['port'] as int?; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (pingLanes.length > 1 || resolutionNote != null) _combinedSummary(context), if (pingLanes.isNotEmpty) Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( context.tr('chartTitle'), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), RttChart([ for (final l in pingLanes) ChartSeries( l.series.family.label, familyColor(l.series.family), l.series.log.items, ), ]), ], ), ), ), for (final l in pingLanes) Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon( Icons.circle, size: 10, color: familyColor(l.series.family), ), const SizedBox(width: 8), Expanded( child: SelectableText( '${l.series.family.label} · ' '${formatEndpoint(l.series.address.address, icmp ? null : tcpPort)}', style: Theme.of(context).textTheme.titleMedium, ), ), ], ), Text( '${context.tr('resolvedAddress')} · ' '${context.tr('protocolUsed')}: ' '${icmp ? (l.series.family == Family.v6 ? 'IPv6 / ICMPv6 echo' : 'IPv4 / ICMP echo') : '${l.series.family.label} / TCP connect'}' ' · ${_status(l.end, l.running, l.error)}', style: mutedStyle(context), ), if (l.running) const LinearProgressIndicator(), if (l.error != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( context.tr(l.error!), style: TextStyle( color: Theme.of(context).colorScheme.error, ), ), ), const SizedBox(height: 12), StatsGrid(l.series.stats.stats()), const SizedBox(height: 12), if (!l.series.log.isEmpty) SampleLogView( l.series.log.items, dropped: l.series.log.dropped, ), ], ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text(context.tr('statsNote'), style: mutedStyle(context)), ), ], ); } /// Both IP versions together; a version that failed or had no address is /// listed with the reason, never left out. Widget _combinedSummary(BuildContext context) { final error = Theme.of(context).colorScheme.error; final rows = [ TableRow( children: [ for (final h in [ 'addressFamily', 'resolvedAddress', 'successRate', 'rttAvg', 'statusColumn', ]) Padding( padding: const EdgeInsets.all(6), child: Text(context.tr(h), style: mutedStyle(context)), ), ], ), for (final l in pingLanes) TableRow( children: [ _cell(l.series.family.label), _cell(l.series.address.address), _cell( l.series.stats.attempts == 0 ? '—' : '${formatPercent(context, l.series.stats.stats().successPercent)} ' '(${l.series.stats.successes}/${l.series.stats.attempts})', color: l.series.stats.attempts > 0 && l.series.stats.successes == 0 ? error : null, ), _cell( l.series.stats.stats().mean == null ? '—' : '${formatMs(context, l.series.stats.stats().mean!)} ms', ), _cell( _status(l.end, l.running, l.error), color: l.error == null ? null : error, ), ], ), for (final (family, why) in _missing) TableRow( children: [ _cell(family), _cell('—'), _cell('—'), _cell('—'), _cell(context.tr(why), color: error), ], ), ]; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( context.tr('combinedSummary'), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Table( defaultColumnWidth: const IntrinsicColumnWidth(), children: rows, ), ), ], ), ), ); } Widget _cell(String text, {Color? color}) => Padding( padding: const EdgeInsets.all(6), child: Text( text, style: TextStyle(color: color, fontFeatures: tabular), ), ); Widget _traceResults(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (family, why) in _missing) Card( child: ListTile( leading: Icon( Icons.info_outline, color: Theme.of(context).colorScheme.error, ), title: Text('$family: ${context.tr(why)}'), ), ), if (traceStates.any((t) => t.previous != null)) SwitchListTile.adaptive( title: Text(context.tr('compareRuns')), value: compare, onChanged: (v) => setState(() => compare = v), ), for (final t in traceStates) _traceCard(context, t), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( '${context.tr('timeoutStar')}\n${context.tr('icmpCodeLegend')}', style: mutedStyle(context), ), ), ], ); Widget _traceCard(BuildContext context, _TraceState t) { final lane = t.lane; final v6 = lane.family == Family.v6; final scheme = Theme.of(context).colorScheme; final methodLabel = switch (resultMethod) { TraceMethod.icmp => v6 ? 'ICMPv6 echo' : 'ICMP echo', TraceMethod.udp => 'UDP', TraceMethod.tcp => 'TCP SYN', }; String probeText(ProbeEvent p) { if (p.status == ProbeStatus.timeout) return '*'; if (p.status == ProbeStatus.sendFailed) { return p.note == 'messageTooLong' ? '! ${context.tr('messageTooLong')}' : '! ${p.note ?? ''}'; } final marker = p.status == ProbeStatus.unreachable ? unreachableMarker(p.icmpCode, v6: v6) : ''; return '${formatMs(context, p.rttMs!)} ms${marker.isEmpty ? '' : ' $marker'}'; } final changes = compare && t.previous != null ? { for (final c in compareTraces(t.previous!, lane.snapshot())) c.ttl: c, } : const {}; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon(Icons.circle, size: 10, color: familyColor(lane.family)), const SizedBox(width: 8), Expanded( child: SelectableText( '${lane.family.label} → ${lane.target.address}', style: Theme.of(context).textTheme.titleMedium, ), ), ], ), Text( '${context.tr('protocolUsed')}: $methodLabel' '${lane.cycles > 1 ? ' · ${context.tr('repetitions')}: ${lane.cycles}' : ''}' ' · ${_status(t.end, t.running, t.error)}', style: mutedStyle(context), ), if (t.running) const LinearProgressIndicator(), if (t.error != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( context.tr(t.error!), style: TextStyle(color: scheme.error), ), ), const SizedBox(height: 8), for (final h in lane.ordered) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 30, child: Text( '${h.ttl}', textAlign: TextAlign.end, style: const TextStyle(fontFeatures: tabular), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( h.addresses.isEmpty ? '*' : h.addresses.keys .map( (a) => names[a] == null ? a : '${names[a]} ($a)', ) .join('\n'), ), Text( h.latest.map(probeText).join(' '), style: const TextStyle(fontFeatures: tabular), ), _hopStats(context, h, lane.cycles), if (h.latest.any((p) => p.note == 'tcpOpen')) Text( context.tr('tcpOpenNote'), style: mutedStyle(context), ), if (h.latest.any((p) => p.note == 'tcpRefused')) Text( context.tr('tcpRefusedNote'), style: mutedStyle(context), ), if (h.latest.any( (p) => p.note == 'portUnreachableOther', )) Text( context.tr('answeredOtherAddress'), style: mutedStyle(context), ), if (changes[h.ttl] case final c?) Text( [ if (c.pathChanged) context.tr('pathChanged'), if (c.meanDelta != null) 'Δ ${c.meanDelta! >= 0 ? '+' : ''}' '${formatMs(context, c.meanDelta!)} ms', '${context.tr('previousRun')}: ' '${c.before == null || c.before!.addresses.isEmpty ? '*' : c.before!.addresses.join(', ')}', ].join(' · '), style: mutedStyle(context).copyWith( color: c.pathChanged ? Brand.amber : null, ), ), ], ), ), ], ), ), ], ), ), ); } Widget _hopStats(BuildContext context, HopAggregate h, int cycles) { final s = h.stats.stats(); if (s.successes == 0) { return Text(context.tr('probeTimeout'), style: mutedStyle(context)); } return Text( '${context.tr('rttStats')}: ${formatMs(context, s.min!)} / ' '${formatMs(context, s.mean!)} / ${formatMs(context, s.max!)} ms' '${cycles > 1 || s.failures > 0 ? ' · ${context.tr('packetLoss')} ${formatPercent(context, s.lossPercent)}' : ''}' '${h.markers.isEmpty ? '' : ' · ${h.markers.join(' ')}'}', style: mutedStyle(context).copyWith(fontFeatures: tabular), ); } @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final results = [ if (error != null) Card( color: Theme.of(context).colorScheme.errorContainer, child: ListTile( leading: const Icon(Icons.error_outline), title: Text(context.tr(error!)), ), ), if (resultKind != null) ...[ _actions(context), if (resultKind == MeasureKind.trace) _traceResults(context) else _pingResults(context), ], if (widget.controller.ssh.connected) Card( child: ListTile( leading: const Icon(Icons.link, color: Brand.green), title: Text(context.tr('connected')), subtitle: Text( '${widget.controller.ssh.profile!.host} · ' '${context.tr('handshake')}: ${widget.controller.ssh.handshakeMs} ms', ), ), ), ]; if (constraints.maxWidth >= 980) { // Tablets and large windows: settings and live results side by side. return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 420, child: ListView( padding: const EdgeInsets.all(16), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, children: [_settings(context)], ), ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(0, 16, 16, 16), children: results, ), ), ], ); } return ListView( padding: readablePadding(constraints, maxWidth: 900), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, children: [_settings(context), ...results], ); }, ); }