CH-J SM MobileSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J SM Mobile

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / SM Mobile 175 Dateien
sm-mobile-20261016T185235Z-8e93ecc1b59a Hinzugefügt: · Erstellt:
diagnostics_page.dart Hinzugefügt: Datum nicht erfasst 43,0 KB · 1299 Zeilen
Code Datei herunterladen
1import 'dart:async';
2import 'dart:io';
3import 'package:flutter/material.dart';
4import 'package:flutter/services.dart';
5import '../core/app_controller.dart';
6import '../core/failure.dart';
7import '../l10n/strings.dart';
8import '../models/server_profile.dart';
9import '../services/net/icmp_probe.dart';
10import '../services/net/measure.dart';
11import '../services/net/tcp_ping.dart';
12import '../services/net/trace_model.dart';
13import '../services/ssh_service.dart';
14import 'adaptive.dart';
15import 'export_sheet.dart';
16import 'net_widgets.dart';
17import 'theme.dart';
19enum DiagnosticTest { tcp, ping, traceroute }
21/// Network operations used by the page; replaceable in tests.
22class NetTools {
23 final Lookup lookup;
24 final PingRun Function(InternetAddress, TcpPingSettings) tcp;
25 final IcmpRun Function(InternetAddress, PingSettings) ping;
26 final IcmpRun Function(InternetAddress, TraceSettings) traceroute;
27 final Future<String?> Function(InternetAddress) reverse;
28 final String? Function(TraceMethod) unsupported;
29 NetTools({
30 Lookup? lookup,
31 PingRun Function(InternetAddress, TcpPingSettings)? tcp,
32 IcmpRun Function(InternetAddress, PingSettings)? ping,
33 IcmpRun Function(InternetAddress, TraceSettings)? traceroute,
34 Future<String?> Function(InternetAddress)? reverse,
35 String? Function(TraceMethod)? unsupported,
36 }) : lookup = lookup ?? systemLookup,
37 tcp = tcp ?? tcpPing,
38 ping = ping ?? IcmpProbe.ping,
39 traceroute = traceroute ?? IcmpProbe.traceroute,
40 reverse = reverse ?? _reverse,
41 unsupported = unsupported ?? IcmpProbe.unsupported;
43 static Future<String?> _reverse(InternetAddress a) async {
44 try {
45 final r = await a.reverse().timeout(const Duration(seconds: 5));
46 return r.host == a.address ? null : r.host;
47 } catch (_) {
48 return null;
49 }
50 }
53/// A ping (TCP or ICMP) of one IP version.
54class _PingLane {
55 final Series series;
56 Future<void> Function()? cancel;
57 StreamSubscription<Object>? subscription;
58 String? error, end;
59 _PingLane(Family family, InternetAddress address)
60 : series = Series(family, address);
61 bool get running => cancel != null;
64/// A traceroute of one IP version.
65class _TraceState {
66 final TraceLane lane;
67 IcmpRun? run;
68 StreamSubscription<ProbeEvent>? subscription;
69 String? error, end;
70 Map<int, HopSnapshot>? previous;
71 _TraceState(this.lane);
72 bool get running => run != null;
75const _presets = ['4', '10', '20', '50', '100', 'custom', 'continuous'];
77class DiagnosticsPage extends StatefulWidget {
78 final AppController controller;
79 final NetTools tools;
80 DiagnosticsPage(this.controller, {NetTools? tools, super.key})
81 : tools = tools ?? NetTools();
82 @override
83 State<DiagnosticsPage> createState() => _DiagnosticsPageState();
86class _DiagnosticsPageState extends State<DiagnosticsPage> {
87 late final host = TextEditingController(
88 text: widget.controller.ssh.profile?.host,
89 );
90 late final port = TextEditingController(
91 text: '${widget.controller.ssh.profile?.port ?? 22}',
92 );
93 final customCount = TextEditingController(text: '30');
94 final interval = TextEditingController(text: '1');
95 final timeout = TextEditingController(text: '2');
96 final size = TextEditingController(text: '56');
97 final ttl = TextEditingController();
98 final maxHops = TextEditingController(text: '30');
99 final probes = TextEditingController(text: '3');
100 final firstTtl = TextEditingController(text: '1');
101 final probeInterval = TextEditingController(text: '0');
102 final tracePort = TextEditingController();
103 final repetitions = TextEditingController(text: '1');
104 DiagnosticTest test = DiagnosticTest.tcp;
105 IpMode mode = IpMode.auto;
106 String preset = '4';
107 bool dontFragment = false, reverseDns = true;
108 TraceMethod method = TraceMethod.icmp;
110 // Resolution and the chosen address per IP version.
111 Resolution? resolution;
112 final chosen = <Family, InternetAddress>{};
113 String? resolutionNote;
115 // Results.
116 final pingLanes = <_PingLane>[];
117 final traceStates = <_TraceState>[];
118 final names = <String, String>{};
119 final _lookups = <String>{};
120 // Snapshots of the last finished traceroute per IP version.
121 final _lastTrace = <Family, Map<int, HopSnapshot>>{};
122 MeasureKind? resultKind;
123 TraceMethod resultMethod = TraceMethod.icmp;
124 Map<String, Object?> resultSettings = const {};
125 String resultHost = '';
126 IpMode resultMode = IpMode.auto;
127 String? error;
128 bool busy = false, compare = false;
129 int _repeatLeft = 0;
130 Timer? _ticker;
132 bool get running =>
133 pingLanes.any((l) => l.running) || traceStates.any((t) => t.running);
135 @override
136 void dispose() {
137 _ticker?.cancel();
138 _stopAll();
139 for (final c in [
140 host,
141 port,
142 customCount,
143 interval,
144 timeout,
145 size,
146 ttl,
147 maxHops,
148 probes,
149 firstTtl,
150 probeInterval,
151 tracePort,
152 repetitions,
153 ]) {
154 c.dispose();
155 }
156 super.dispose();
157 }
159 void _stopAll() {
160 for (final l in pingLanes) {
161 final cancel = l.cancel, sub = l.subscription;
162 l.cancel = null;
163 l.subscription = null;
164 unawaited(cancel?.call());
165 unawaited(sub?.cancel());
166 }
167 for (final t in traceStates) {
168 final run = t.run, sub = t.subscription;
169 t.run = null;
170 t.subscription = null;
171 unawaited(run?.cancel());
172 unawaited(sub?.cancel());
173 }
174 }
176 void stop() {
177 for (final l in pingLanes) {
178 if (l.running) l.end = 'stoppedByUser';
179 }
180 for (final t in traceStates) {
181 if (t.running) t.end = 'stoppedByUser';
182 }
183 _repeatLeft = 0;
184 _stopAll();
185 _ticker?.cancel();
186 setState(() => busy = false);
187 }
189 double _number(TextEditingController c, double min, double max) {
190 final v = double.tryParse(c.text.trim().replaceAll(',', '.'));
191 if (v == null || v < min || v > max) throw const Failure('invalidSetting');
192 return v;
193 }
195 int _int(TextEditingController c, int min, int max) {
196 final v = int.tryParse(c.text.trim());
197 if (v == null || v < min || v > max) throw const Failure('invalidSetting');
198 return v;
199 }
201 Duration _seconds(double v) => Duration(microseconds: (v * 1e6).round());
203 int get _count => preset == 'continuous'
204 ? 0
205 : preset == 'custom'
206 ? _int(customCount, 1, 100000)
207 : int.parse(preset);
209 String _hostText() {
210 final target = host.text.trim();
211 normalizeHost(target);
212 return target;
213 }
215 String _code(Object e) {
216 if (e is Failure) return e.code;
217 if (e is SocketException) return 'dnsFailed';
218 return failureCode(e);
219 }
221 /// Looks up the addresses so a specific one can be chosen.
222 Future<void> findAddresses() async {
223 FocusManager.instance.primaryFocus?.unfocus();
224 setState(() {
225 busy = true;
226 error = null;
227 });
228 try {
229 final r = await resolveTarget(
230 _hostText(),
231 mode == IpMode.auto ? IpMode.dual : mode,
232 lookup: widget.tools.lookup,
233 );
234 if (!mounted) return;
235 setState(() {
236 resolution = r;
237 chosen
238 ..clear()
239 ..addAll({
240 for (final f in r.families)
241 if (f.available) f.family: f.addresses.first,
242 });
243 });
244 } catch (e) {
245 if (mounted) setState(() => error = _code(e));
246 } finally {
247 if (mounted) setState(() => busy = false);
248 }
249 }
251 /// The addresses to measure: one per IP version for the selected mode,
252 /// with the reason when a version has no address.
253 Future<List<(Family, InternetAddress?, String?)>> _targets(
254 String target,
255 ) async {
256 final r = await resolveTarget(target, mode, lookup: widget.tools.lookup);
257 InternetAddress? pinned(Family f, List<InternetAddress> list) {
258 final a = resolution?.host == target ? chosen[f] : null;
259 return a != null && list.contains(a) ? a : null;
260 }
262 if (mode == IpMode.auto) {
263 final p = r.preferred!;
264 final family = Family.of(p);
265 return [(family, pinned(family, r.of(family)!.addresses) ?? p, null)];
266 }
267 return [
268 for (final f in r.families)
269 (
270 f.family,
271 f.available
272 ? pinned(f.family, f.addresses) ?? f.addresses.first
273 : null,
274 f.missing,
275 ),
276 ];
277 }
279 Future<void> run() async {
280 FocusManager.instance.primaryFocus?.unfocus();
281 _stopAll();
282 setState(() {
283 busy = true;
284 error = null;
285 pingLanes.clear();
286 for (final t in traceStates) {
287 if (t.lane.hops.isNotEmpty) {
288 _lastTrace[t.lane.family] = t.lane.snapshot();
289 }
290 }
291 traceStates.clear();
292 });
293 try {
294 final target = _hostText();
295 late final Map<String, Object?> settings;
296 late final void Function(Family, InternetAddress) start;
297 switch (test) {
298 case DiagnosticTest.tcp:
299 final s = TcpPingSettings(
300 port: _int(port, 1, 65535),
301 count: _count,
302 interval: _seconds(_number(interval, 0, 3600)),
303 timeout: _seconds(_number(timeout, .1, 60)),
304 );
305 settings = s.toJson();
306 start = (f, a) => _startTcp(f, a, s);
307 case DiagnosticTest.ping:
308 final s = PingSettings(
309 count: _count,
310 interval: _seconds(_number(interval, .2, 3600)),
311 timeout: _seconds(_number(timeout, .1, 60)),
312 size: _int(size, 0, 65000),
313 ttl: ttl.text.trim().isEmpty ? null : _int(ttl, 1, 255),
314 dontFragment: dontFragment,
315 );
316 settings = s.toJson();
317 start = (f, a) => _startPing(f, a, s);
318 case DiagnosticTest.traceroute:
319 final hops = _int(maxHops, 1, 64);
320 final s = TraceSettings(
321 maxHops: hops,
322 probes: _int(probes, 1, 10),
323 timeout: _seconds(_number(timeout, .1, 30)),
324 firstTtl: _int(firstTtl, 1, hops),
325 interval: _seconds(_number(probeInterval, 0, 60)),
326 method: method,
327 port: method == TraceMethod.icmp || tracePort.text.trim().isEmpty
328 ? null
329 : _int(tracePort, 1, 65535),
330 );
331 // A method the platform cannot do is never replaced by another.
332 final unsupported = widget.tools.unsupported(method);
333 if (unsupported != null) throw Failure(unsupported);
334 _repeatLeft = _int(repetitions, 1, 1000) - 1;
335 settings = {
336 ...s.toJson(),
337 'repetitions': _repeatLeft + 1,
338 'reverseDns': reverseDns,
339 };
340 start = (f, a) => _startTrace(f, a, s);
341 }
342 final targets = await _targets(target);
343 if (!mounted || !widget.controller.vault.unlocked) return;
344 setState(() {
345 resultKind = switch (test) {
346 DiagnosticTest.tcp => MeasureKind.tcp,
347 DiagnosticTest.ping => MeasureKind.icmp,
348 DiagnosticTest.traceroute => MeasureKind.trace,
349 };
350 resultMethod = method;
351 resultSettings = settings;
352 resultHost = target;
353 resultMode = mode;
354 final missing = [
355 for (final (f, a, why) in targets)
356 if (a == null) '${f.label}: ${why ?? 'noAddressFamily'}',
357 ];
358 resolutionNote = missing.isEmpty ? null : missing.join('\n');
359 for (final (f, a, _) in targets) {
360 if (a != null) start(f, a);
361 }
362 if (!running) busy = false;
363 });
364 _ticker?.cancel();
365 _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
366 if (mounted && running) setState(() {});
367 });
368 } catch (e) {
369 if (mounted) {
370 setState(() {
371 error = _code(e);
372 busy = false;
373 });
374 }
375 }
376 }
378 void _laneDone() {
379 if (!mounted) return;
380 setState(() {
381 if (!running) {
382 busy = false;
383 _ticker?.cancel();
384 }
385 });
386 }
388 void _startTcp(Family f, InternetAddress a, TcpPingSettings s) {
389 final lane = _PingLane(f, a);
390 final run = widget.tools.tcp(a, s);
391 lane.cancel = run.cancel;
392 lane.subscription = run.samples.listen(
393 (sample) {
394 if (mounted) setState(() => lane.series.add(sample));
395 },
396 onDone: () {
397 lane.cancel = null;
398 lane.end ??= 'statusFinished';
399 _laneDone();
400 },
401 );
402 pingLanes.add(lane);
403 }
405 void _startPing(Family f, InternetAddress a, PingSettings s) {
406 final lane = _PingLane(f, a);
407 final run = widget.tools.ping(a, s);
408 lane.cancel = run.cancel;
409 lane.subscription = run.events.listen(
410 (e) {
411 if (mounted) setState(() => lane.series.add(_pingSample(e, f, a)));
412 },
413 onError: (Object e) {
414 if (mounted) {
415 setState(() => lane.error = e is IcmpUnavailable ? e.code : _code(e));
416 }
417 },
418 onDone: () {
419 lane.cancel = null;
420 if (lane.error == null) lane.end ??= 'statusFinished';
421 _laneDone();
422 },
423 );
424 pingLanes.add(lane);
425 }
427 static Sample _pingSample(ProbeEvent e, Family f, InternetAddress a) {
428 final detail = [
429 if (e.from != null && e.from != a) e.from!.address,
430 if (e.icmpCode != null && e.status != ProbeStatus.reply)
431 'code ${e.icmpCode}',
432 ?e.note,
433 ].join(', ');
434 return Sample(
435 seq: e.seq,
436 time: e.time ?? DateTime.now(),
437 family: f,
438 address: a.address,
439 rttMs: e.status == ProbeStatus.reply ? e.rttMs : null,
440 outcome: switch (e.status) {
441 ProbeStatus.reply => Outcome.ok,
442 ProbeStatus.timeout => Outcome.timeout,
443 ProbeStatus.timeExceeded => Outcome.ttlExceeded,
444 ProbeStatus.unreachable => Outcome.unreachable,
445 ProbeStatus.sendFailed => Outcome.error,
446 },
447 detail: detail.isEmpty ? null : detail,
448 );
449 }
451 void _startTrace(Family f, InternetAddress a, TraceSettings s) {
452 final state = _TraceState(TraceLane(f, a))..previous = _lastTrace[f];
453 traceStates.add(state);
454 _traceCycle(state, s);
455 }
457 void _traceCycle(_TraceState state, TraceSettings s) {
458 state.lane.nextCycle();
459 final run = widget.tools.traceroute(state.lane.target, s);
460 state.run = run;
461 state.end = null;
462 state.subscription = run.events.listen(
463 (e) {
464 if (!mounted) return;
465 setState(() => state.lane.add(e));
466 if (reverseDns && e.from != null) _resolveName(e.from!);
467 },
468 onError: (Object e) {
469 if (mounted) {
470 setState(
471 () => state.error = e is IcmpUnavailable ? e.code : _code(e),
472 );
473 }
474 },
475 onDone: () {
476 if (state.run == null) return; // stopped
477 state.run = null;
478 if (state.error == null) {
479 state.end = state.lane.reached
480 ? 'traceReached'
481 : state.lane.hops.values.any((h) => h.markers.isNotEmpty)
482 ? 'traceUnreachable'
483 : state.lane.hops.isEmpty
484 ? null
485 : 'traceLimit';
486 }
487 // Repetitions start when every IP version finished its run.
488 if (_repeatLeft > 0 &&
489 mounted &&
490 traceStates.every((t) => !t.running)) {
491 _repeatLeft--;
492 for (final t in traceStates) {
493 if (t.error == null) _traceCycle(t, s);
494 }
495 }
496 _laneDone();
497 },
498 );
499 }
501 Future<void> _resolveName(InternetAddress a) async {
502 if (!_lookups.add(a.address)) return;
503 final name = await widget.tools.reverse(a);
504 if (name != null && mounted) setState(() => names[a.address] = name);
505 }
507 void clearResults() {
508 _stopAll();
509 _ticker?.cancel();
510 setState(() {
511 pingLanes.clear();
512 traceStates.clear();
513 resultKind = null;
514 error = null;
515 resolutionNote = null;
516 busy = false;
517 });
518 }
520 String _report(ReportFormat format) => resultKind == MeasureKind.trace
521 ? traceReport(
522 format,
523 host: resultHost,
524 mode: resultMode,
525 settings: resultSettings,
526 lanes: [for (final t in traceStates) t.lane],
527 names: names,
528 )
529 : pingReport(
530 format,
531 kind: resultKind!,
532 host: resultHost,
533 mode: resultMode,
534 settings: resultSettings,
535 series: [for (final l in pingLanes) l.series],
536 );
538 Future<void> copyResults() async {
539 await Clipboard.setData(ClipboardData(text: _report(ReportFormat.txt)));
540 if (mounted) {
541 ScaffoldMessenger.of(
542 context,
543 ).showSnackBar(SnackBar(content: Text(context.tr('copied'))));
544 }
545 }
547 // ---------------------------------------------------------------------------
548 // Settings
550 Widget _field(
551 TextEditingController c,
552 String label, {
553 bool decimal = true,
554 String? hint,
555 }) => TextField(
556 controller: c,
557 enabled: !running,
558 keyboardType: TextInputType.numberWithOptions(decimal: decimal),
559 decoration: InputDecoration(labelText: context.tr(label), hintText: hint),
560 );
562 Widget _settings(BuildContext context) {
563 final trace = test == DiagnosticTest.traceroute;
564 final ping = test == DiagnosticTest.ping;
565 final locked = running || busy;
566 return Card(
567 child: Padding(
568 padding: const EdgeInsets.all(16),
569 child: Column(
570 crossAxisAlignment: CrossAxisAlignment.stretch,
571 children: [
572 SegmentedButton<DiagnosticTest>(
573 showSelectedIcon: false,
574 segments: [
575 ButtonSegment(
576 value: DiagnosticTest.tcp,
577 label: Text(context.tr('tcpPing')),
578 ),
579 ButtonSegment(
580 value: DiagnosticTest.ping,
581 label: Text(context.tr('icmpPing')),
582 ),
583 ButtonSegment(
584 value: DiagnosticTest.traceroute,
585 label: Text(context.tr('traceroute')),
586 ),
587 ],
588 selected: {test},
589 onSelectionChanged: locked
590 ? null
591 : (v) => setState(() => test = v.first),
592 ),
593 const SizedBox(height: 8),
594 Text(
595 context.tr(switch (test) {
596 DiagnosticTest.tcp => 'tcpNote',
597 DiagnosticTest.ping => 'icmpPingNote',
598 DiagnosticTest.traceroute => 'traceNote',
599 }),
600 style: mutedStyle(context),
601 ),
602 const SizedBox(height: 12),
603 TextField(
604 controller: host,
605 enabled: !running,
606 autocorrect: false,
607 keyboardType: TextInputType.url,
608 decoration: InputDecoration(labelText: context.tr('host')),
609 onChanged: (_) {
610 if (resolution != null) setState(() => resolution = null);
611 },
612 ),
613 if (test == DiagnosticTest.tcp) ...[
614 const SizedBox(height: 12),
615 _field(port, 'tcpPort', decimal: false),
616 ],
617 const SizedBox(height: 16),
618 Text(context.tr('addressFamily'), style: mutedStyle(context)),
619 const SizedBox(height: 8),
620 SegmentedButton<IpMode>(
621 showSelectedIcon: false,
622 segments: [
623 ButtonSegment(
624 value: IpMode.auto,
625 label: Text(context.tr('automatic')),
626 ),
627 const ButtonSegment(value: IpMode.v4, label: Text('IPv4')),
628 const ButtonSegment(value: IpMode.v6, label: Text('IPv6')),
629 const ButtonSegment(value: IpMode.dual, label: Text('4 + 6')),
630 ],
631 selected: {mode},
632 onSelectionChanged: locked
633 ? null
634 : (v) => setState(() => mode = v.first),
635 ),
636 const SizedBox(height: 4),
637 _addressPicker(context),
638 if (!trace) ...[
639 const SizedBox(height: 8),
640 DropdownButtonFormField<String>(
641 initialValue: preset,
642 isExpanded: true,
643 decoration: InputDecoration(
644 labelText: context.tr(ping ? 'packetCount' : 'attemptCount'),
645 ),
646 items: [
647 for (final p in _presets)
648 DropdownMenuItem(
649 value: p,
650 child: Text(
651 p == 'custom'
652 ? context.tr('customValue')
653 : p == 'continuous'
654 ? context.tr('continuous')
655 : p,
656 ),
657 ),
658 ],
659 onChanged: locked
660 ? null
661 : (v) => setState(() => preset = v ?? '4'),
662 ),
663 if (preset == 'custom') ...[
664 const SizedBox(height: 12),
665 _field(customCount, 'customCount', decimal: false),
666 ],
667 ],
668 if (trace) ...[
669 const SizedBox(height: 12),
670 Text(context.tr('traceMethod'), style: mutedStyle(context)),
671 const SizedBox(height: 8),
672 SegmentedButton<TraceMethod>(
673 showSelectedIcon: false,
674 segments: [
675 for (final m in TraceMethod.values)
676 ButtonSegment(
677 value: m,
678 enabled: widget.tools.unsupported(m) == null,
679 label: Text(m.name.toUpperCase()),
680 ),
681 ],
682 selected: {method},
683 onSelectionChanged: locked
684 ? null
685 : (v) => setState(() => method = v.first),
686 ),
687 for (final m in TraceMethod.values)
688 if (widget.tools.unsupported(m) case final why?)
689 Padding(
690 padding: const EdgeInsets.only(top: 6),
691 child: Text(
692 '${m.name.toUpperCase()}: ${context.tr(why)}',
693 style: mutedStyle(context),
694 ),
695 ),
696 ],
697 ExpansionTile(
698 tilePadding: EdgeInsets.zero,
699 title: Text(context.tr('testOptions')),
700 childrenPadding: const EdgeInsets.only(bottom: 8),
701 children: [
702 AdaptiveGrid(
703 minTileWidth: 150,
704 children: [
705 if (!trace) _field(interval, 'intervalSeconds'),
706 _field(timeout, 'timeoutSeconds'),
707 if (ping) ...[
708 _field(size, 'payloadBytes', decimal: false),
709 _field(
710 ttl,
711 'ttlHopLimit',
712 decimal: false,
713 hint: context.tr('systemDefault'),
714 ),
715 ],
716 if (trace) ...[
717 _field(maxHops, 'maxHops', decimal: false),
718 _field(probes, 'probesPerHop', decimal: false),
719 _field(firstTtl, 'firstTtl', decimal: false),
720 _field(probeInterval, 'probeInterval'),
721 if (method != TraceMethod.icmp)
722 _field(
723 tracePort,
724 'tracePort',
725 decimal: false,
726 hint: method == TraceMethod.tcp
727 ? '80'
728 : '${TraceSettings.defaultUdpPort}',
729 ),
730 _field(repetitions, 'repetitions', decimal: false),
731 ],
732 ],
733 ),
734 if (ping)
735 SwitchListTile.adaptive(
736 contentPadding: EdgeInsets.zero,
737 title: Text(context.tr('dontFragment')),
738 value: dontFragment,
739 onChanged: locked
740 ? null
741 : (v) => setState(() => dontFragment = v),
742 ),
743 if (trace)
744 SwitchListTile.adaptive(
745 contentPadding: EdgeInsets.zero,
746 title: Text(context.tr('reverseDns')),
747 value: reverseDns,
748 onChanged: locked
749 ? null
750 : (v) => setState(() => reverseDns = v),
751 ),
752 ],
753 ),
754 const SizedBox(height: 8),
755 if (running)
756 FilledButton.tonalIcon(
757 onPressed: stop,
758 icon: const Icon(Icons.stop),
759 label: Text(context.tr('stop')),
760 )
761 else
762 FilledButton.icon(
763 onPressed: busy ? null : run,
764 icon: busy
765 ? const SizedBox.square(
766 dimension: 18,
767 child: CircularProgressIndicator(strokeWidth: 2),
768 )
769 : const Icon(Icons.play_arrow),
770 label: Text(context.tr('runDiagnostics')),
771 ),
772 ],
773 ),
774 ),
775 );
776 }
778 Widget _addressPicker(BuildContext context) {
779 final r = resolution;
780 return Column(
781 crossAxisAlignment: CrossAxisAlignment.stretch,
782 children: [
783 Align(
784 alignment: AlignmentDirectional.centerStart,
785 child: TextButton.icon(
786 onPressed: running || busy ? null : findAddresses,
787 icon: const Icon(Icons.dns_outlined, size: 18),
788 label: Text(context.tr('resolveAddresses')),
789 ),
790 ),
791 if (r != null)
792 for (final f in r.families)
793 if (f.available)
794 Padding(
795 padding: const EdgeInsets.only(bottom: 6),
796 child: Wrap(
797 spacing: 6,
798 runSpacing: 6,
799 crossAxisAlignment: WrapCrossAlignment.center,
800 children: [
801 Text(f.family.label, style: mutedStyle(context)),
802 for (final a in f.addresses)
803 ChoiceChip(
804 label: Text(a.address),
805 selected: chosen[f.family] == a,
806 onSelected: running
807 ? null
808 : (_) => setState(() => chosen[f.family] = a),
809 ),
810 ],
811 ),
812 )
813 else
814 Padding(
815 padding: const EdgeInsets.only(bottom: 6),
816 child: Text(
817 '${f.family.label}: ${context.tr(f.missing ?? 'noAddressFamily')}',
818 style: mutedStyle(context),
819 ),
820 ),
821 ],
822 );
823 }
825 // ---------------------------------------------------------------------------
826 // Results
828 Widget _actions(BuildContext context) => Wrap(
829 spacing: 8,
830 runSpacing: 4,
831 children: [
832 TextButton.icon(
833 onPressed: copyResults,
834 icon: const Icon(Icons.copy, size: 18),
835 label: Text(context.tr('copyResults')),
836 ),
837 TextButton.icon(
838 onPressed: () => showExportSheet(
839 context,
840 widget.controller,
841 baseName:
842 'sm-${resultKind!.name}-'
843 '${resultHost.replaceAll(RegExp(r'[^A-Za-z0-9.-]'), '_')}',
844 build: _report,
845 ),
846 icon: const Icon(Icons.ios_share, size: 18),
847 label: Text(context.tr('exportResults')),
848 ),
849 TextButton.icon(
850 onPressed: running ? null : clearResults,
851 icon: const Icon(Icons.delete_outline, size: 18),
852 label: Text(context.tr('clearResults')),
853 ),
854 ],
855 );
857 String _status(String? end, bool running, String? error) => running
858 ? context.tr('statusRunning')
859 : error != null
860 ? context.tr(error)
861 : end != null
862 ? context.tr(end)
863 : '—';
865 List<(String, String)> get _missing => [
866 if (resolutionNote != null)
867 for (final line in resolutionNote!.split('\n'))
868 (line.split(': ').first, line.split(': ').last),
869 ];
871 Widget _pingResults(BuildContext context) {
872 final icmp = resultKind == MeasureKind.icmp;
873 final tcpPort = resultSettings['port'] as int?;
874 return Column(
875 crossAxisAlignment: CrossAxisAlignment.stretch,
876 children: [
877 if (pingLanes.length > 1 || resolutionNote != null)
878 _combinedSummary(context),
879 if (pingLanes.isNotEmpty)
880 Card(
881 child: Padding(
882 padding: const EdgeInsets.all(16),
883 child: Column(
884 crossAxisAlignment: CrossAxisAlignment.stretch,
885 children: [
886 Text(
887 context.tr('chartTitle'),
888 style: Theme.of(context).textTheme.titleMedium,
889 ),
890 const SizedBox(height: 8),
891 RttChart([
892 for (final l in pingLanes)
893 ChartSeries(
894 l.series.family.label,
895 familyColor(l.series.family),
896 l.series.log.items,
897 ),
898 ]),
899 ],
900 ),
901 ),
902 ),
903 for (final l in pingLanes)
904 Card(
905 child: Padding(
906 padding: const EdgeInsets.all(16),
907 child: Column(
908 crossAxisAlignment: CrossAxisAlignment.stretch,
909 children: [
910 Row(
911 children: [
912 Icon(
913 Icons.circle,
914 size: 10,
915 color: familyColor(l.series.family),
916 ),
917 const SizedBox(width: 8),
918 Expanded(
919 child: SelectableText(
920 '${l.series.family.label} · '
921 '${formatEndpoint(l.series.address.address, icmp ? null : tcpPort)}',
922 style: Theme.of(context).textTheme.titleMedium,
923 ),
924 ),
925 ],
926 ),
927 Text(
928 '${context.tr('resolvedAddress')} · '
929 '${context.tr('protocolUsed')}: '
930 '${icmp ? (l.series.family == Family.v6 ? 'IPv6 / ICMPv6 echo' : 'IPv4 / ICMP echo') : '${l.series.family.label} / TCP connect'}'
931 ' · ${_status(l.end, l.running, l.error)}',
932 style: mutedStyle(context),
933 ),
934 if (l.running) const LinearProgressIndicator(),
935 if (l.error != null)
936 Padding(
937 padding: const EdgeInsets.only(top: 8),
938 child: Text(
939 context.tr(l.error!),
940 style: TextStyle(
941 color: Theme.of(context).colorScheme.error,
942 ),
943 ),
944 ),
945 const SizedBox(height: 12),
946 StatsGrid(l.series.stats.stats()),
947 const SizedBox(height: 12),
948 if (!l.series.log.isEmpty)
949 SampleLogView(
950 l.series.log.items,
951 dropped: l.series.log.dropped,
952 ),
953 ],
954 ),
955 ),
956 ),
957 Padding(
958 padding: const EdgeInsets.symmetric(horizontal: 4),
959 child: Text(context.tr('statsNote'), style: mutedStyle(context)),
960 ),
961 ],
962 );
963 }
965 /// Both IP versions together; a version that failed or had no address is
966 /// listed with the reason, never left out.
967 Widget _combinedSummary(BuildContext context) {
968 final error = Theme.of(context).colorScheme.error;
969 final rows = <TableRow>[
970 TableRow(
971 children: [
972 for (final h in [
973 'addressFamily',
974 'resolvedAddress',
975 'successRate',
976 'rttAvg',
977 'statusColumn',
978 ])
979 Padding(
980 padding: const EdgeInsets.all(6),
981 child: Text(context.tr(h), style: mutedStyle(context)),
982 ),
983 ],
984 ),
985 for (final l in pingLanes)
986 TableRow(
987 children: [
988 _cell(l.series.family.label),
989 _cell(l.series.address.address),
990 _cell(
991 l.series.stats.attempts == 0
992 ? '—'
993 : '${formatPercent(context, l.series.stats.stats().successPercent)} '
994 '(${l.series.stats.successes}/${l.series.stats.attempts})',
995 color:
996 l.series.stats.attempts > 0 && l.series.stats.successes == 0
997 ? error
998 : null,
999 ),
1000 _cell(
1001 l.series.stats.stats().mean == null
1002 ? '—'
1003 : '${formatMs(context, l.series.stats.stats().mean!)} ms',
1004 ),
1005 _cell(
1006 _status(l.end, l.running, l.error),
1007 color: l.error == null ? null : error,
1008 ),
1009 ],
1010 ),
1011 for (final (family, why) in _missing)
1012 TableRow(
1013 children: [
1014 _cell(family),
1015 _cell('—'),
1016 _cell('—'),
1017 _cell('—'),
1018 _cell(context.tr(why), color: error),
1019 ],
1020 ),
1021 ];
1022 return Card(
1023 child: Padding(
1024 padding: const EdgeInsets.all(16),
1025 child: Column(
1026 crossAxisAlignment: CrossAxisAlignment.stretch,
1027 children: [
1028 Text(
1029 context.tr('combinedSummary'),
1030 style: Theme.of(context).textTheme.titleMedium,
1031 ),
1032 const SizedBox(height: 8),
1033 SingleChildScrollView(
1034 scrollDirection: Axis.horizontal,
1035 child: Table(
1036 defaultColumnWidth: const IntrinsicColumnWidth(),
1037 children: rows,
1038 ),
1039 ),
1040 ],
1041 ),
1042 ),
1043 );
1046 Widget _cell(String text, {Color? color}) => Padding(
1047 padding: const EdgeInsets.all(6),
1048 child: Text(
1049 text,
1050 style: TextStyle(color: color, fontFeatures: tabular),
1051 ),
1052 );
1054 Widget _traceResults(BuildContext context) => Column(
1055 crossAxisAlignment: CrossAxisAlignment.stretch,
1056 children: [
1057 for (final (family, why) in _missing)
1058 Card(
1059 child: ListTile(
1060 leading: Icon(
1061 Icons.info_outline,
1062 color: Theme.of(context).colorScheme.error,
1063 ),
1064 title: Text('$family: ${context.tr(why)}'),
1065 ),
1066 ),
1067 if (traceStates.any((t) => t.previous != null))
1068 SwitchListTile.adaptive(
1069 title: Text(context.tr('compareRuns')),
1070 value: compare,
1071 onChanged: (v) => setState(() => compare = v),
1072 ),
1073 for (final t in traceStates) _traceCard(context, t),
1074 Padding(
1075 padding: const EdgeInsets.symmetric(horizontal: 4),
1076 child: Text(
1077 '${context.tr('timeoutStar')}\n${context.tr('icmpCodeLegend')}',
1078 style: mutedStyle(context),
1079 ),
1080 ),
1081 ],
1082 );
1084 Widget _traceCard(BuildContext context, _TraceState t) {
1085 final lane = t.lane;
1086 final v6 = lane.family == Family.v6;
1087 final scheme = Theme.of(context).colorScheme;
1088 final methodLabel = switch (resultMethod) {
1089 TraceMethod.icmp => v6 ? 'ICMPv6 echo' : 'ICMP echo',
1090 TraceMethod.udp => 'UDP',
1091 TraceMethod.tcp => 'TCP SYN',
1092 };
1093 String probeText(ProbeEvent p) {
1094 if (p.status == ProbeStatus.timeout) return '*';
1095 if (p.status == ProbeStatus.sendFailed) {
1096 return p.note == 'messageTooLong'
1097 ? '! ${context.tr('messageTooLong')}'
1098 : '! ${p.note ?? ''}';
1100 final marker = p.status == ProbeStatus.unreachable
1101 ? unreachableMarker(p.icmpCode, v6: v6)
1102 : '';
1103 return '${formatMs(context, p.rttMs!)} ms${marker.isEmpty ? '' : ' $marker'}';
1106 final changes = compare && t.previous != null
1107 ? {
1108 for (final c in compareTraces(t.previous!, lane.snapshot()))
1109 c.ttl: c,
1111 : const <int, HopChange>{};
1112 return Card(
1113 child: Padding(
1114 padding: const EdgeInsets.all(16),
1115 child: Column(
1116 crossAxisAlignment: CrossAxisAlignment.stretch,
1117 children: [
1118 Row(
1119 children: [
1120 Icon(Icons.circle, size: 10, color: familyColor(lane.family)),
1121 const SizedBox(width: 8),
1122 Expanded(
1123 child: SelectableText(
1124 '${lane.family.label} → ${lane.target.address}',
1125 style: Theme.of(context).textTheme.titleMedium,
1126 ),
1127 ),
1128 ],
1129 ),
1130 Text(
1131 '${context.tr('protocolUsed')}: $methodLabel'
1132 '${lane.cycles > 1 ? ' · ${context.tr('repetitions')}: ${lane.cycles}' : ''}'
1133 ' · ${_status(t.end, t.running, t.error)}',
1134 style: mutedStyle(context),
1135 ),
1136 if (t.running) const LinearProgressIndicator(),
1137 if (t.error != null)
1138 Padding(
1139 padding: const EdgeInsets.only(top: 8),
1140 child: Text(
1141 context.tr(t.error!),
1142 style: TextStyle(color: scheme.error),
1143 ),
1144 ),
1145 const SizedBox(height: 8),
1146 for (final h in lane.ordered)
1147 Padding(
1148 padding: const EdgeInsets.symmetric(vertical: 4),
1149 child: Row(
1150 crossAxisAlignment: CrossAxisAlignment.start,
1151 children: [
1152 SizedBox(
1153 width: 30,
1154 child: Text(
1155 '${h.ttl}',
1156 textAlign: TextAlign.end,
1157 style: const TextStyle(fontFeatures: tabular),
1158 ),
1159 ),
1160 const SizedBox(width: 12),
1161 Expanded(
1162 child: Column(
1163 crossAxisAlignment: CrossAxisAlignment.start,
1164 children: [
1165 SelectableText(
1166 h.addresses.isEmpty
1167 ? '*'
1168 : h.addresses.keys
1169 .map(
1170 (a) => names[a] == null
1171 ? a
1172 : '${names[a]} ($a)',
1174 .join('\n'),
1175 ),
1176 Text(
1177 h.latest.map(probeText).join(' '),
1178 style: const TextStyle(fontFeatures: tabular),
1179 ),
1180 _hopStats(context, h, lane.cycles),
1181 if (h.latest.any((p) => p.note == 'tcpOpen'))
1182 Text(
1183 context.tr('tcpOpenNote'),
1184 style: mutedStyle(context),
1185 ),
1186 if (h.latest.any((p) => p.note == 'tcpRefused'))
1187 Text(
1188 context.tr('tcpRefusedNote'),
1189 style: mutedStyle(context),
1190 ),
1191 if (h.latest.any(
1192 (p) => p.note == 'portUnreachableOther',
1193 ))
1194 Text(
1195 context.tr('answeredOtherAddress'),
1196 style: mutedStyle(context),
1197 ),
1198 if (changes[h.ttl] case final c?)
1199 Text(
1201 if (c.pathChanged) context.tr('pathChanged'),
1202 if (c.meanDelta != null)
1203 'Δ ${c.meanDelta! >= 0 ? '+' : ''}'
1204 '${formatMs(context, c.meanDelta!)} ms',
1205 '${context.tr('previousRun')}: '
1206 '${c.before == null || c.before!.addresses.isEmpty ? '*' : c.before!.addresses.join(', ')}',
1207 ].join(' · '),
1208 style: mutedStyle(context).copyWith(
1209 color: c.pathChanged ? Brand.amber : null,
1210 ),
1211 ),
1212 ],
1213 ),
1214 ),
1215 ],
1216 ),
1217 ),
1218 ],
1219 ),
1220 ),
1221 );
1224 Widget _hopStats(BuildContext context, HopAggregate h, int cycles) {
1225 final s = h.stats.stats();
1226 if (s.successes == 0) {
1227 return Text(context.tr('probeTimeout'), style: mutedStyle(context));
1229 return Text(
1230 '${context.tr('rttStats')}: ${formatMs(context, s.min!)} / '
1231 '${formatMs(context, s.mean!)} / ${formatMs(context, s.max!)} ms'
1232 '${cycles > 1 || s.failures > 0 ? ' · ${context.tr('packetLoss')} ${formatPercent(context, s.lossPercent)}' : ''}'
1233 '${h.markers.isEmpty ? '' : ' · ${h.markers.join(' ')}'}',
1234 style: mutedStyle(context).copyWith(fontFeatures: tabular),
1235 );
1238 @override
1239 Widget build(BuildContext context) => LayoutBuilder(
1240 builder: (context, constraints) {
1241 final results = <Widget>[
1242 if (error != null)
1243 Card(
1244 color: Theme.of(context).colorScheme.errorContainer,
1245 child: ListTile(
1246 leading: const Icon(Icons.error_outline),
1247 title: Text(context.tr(error!)),
1248 ),
1249 ),
1250 if (resultKind != null) ...[
1251 _actions(context),
1252 if (resultKind == MeasureKind.trace)
1253 _traceResults(context)
1254 else
1255 _pingResults(context),
1256 ],
1257 if (widget.controller.ssh.connected)
1258 Card(
1259 child: ListTile(
1260 leading: const Icon(Icons.link, color: Brand.green),
1261 title: Text(context.tr('connected')),
1262 subtitle: Text(
1263 '${widget.controller.ssh.profile!.host} · '
1264 '${context.tr('handshake')}: ${widget.controller.ssh.handshakeMs} ms',
1265 ),
1266 ),
1267 ),
1268 ];
1269 if (constraints.maxWidth >= 980) {
1270 // Tablets and large windows: settings and live results side by side.
1271 return Row(
1272 crossAxisAlignment: CrossAxisAlignment.start,
1273 children: [
1274 SizedBox(
1275 width: 420,
1276 child: ListView(
1277 padding: const EdgeInsets.all(16),
1278 keyboardDismissBehavior:
1279 ScrollViewKeyboardDismissBehavior.onDrag,
1280 children: [_settings(context)],
1281 ),
1282 ),
1283 Expanded(
1284 child: ListView(
1285 padding: const EdgeInsets.fromLTRB(0, 16, 16, 16),
1286 children: results,
1287 ),
1288 ),
1289 ],
1290 );
1292 return ListView(
1293 padding: readablePadding(constraints, maxWidth: 900),
1294 keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
1295 children: [_settings(context), ...results],
1296 );
1297 },
1298 );
SHA-256895d8317525841cd97df92929a63c2ede4d6b387f5f1791073d12266dc7b5531

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
Dateien