المصدر المنشور
تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J SM Mobile
تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.
CH-J / SM Mobile
175 ملفات
sm-mobile-20261016T185235Z-8e93ecc1b59a
تاريخ الإضافة: · تاريخ البناء:
1
import 'dart:async';2
import 'dart:io';3
import 'package:flutter/material.dart';4
import 'package:flutter/services.dart';5
import '../core/app_controller.dart';6
import '../core/failure.dart';7
import '../l10n/strings.dart';8
import '../models/server_profile.dart';9
import '../services/net/icmp_probe.dart';10
import '../services/net/measure.dart';11
import '../services/net/tcp_ping.dart';12
import '../services/net/trace_model.dart';13
import '../services/ssh_service.dart';14
import 'adaptive.dart';15
import 'export_sheet.dart';16
import 'net_widgets.dart';17
import 'theme.dart';19
enum DiagnosticTest { tcp, ping, traceroute }21
/// Network operations used by the page; replaceable in tests.22
class 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
}51
}53
/// A ping (TCP or ICMP) of one IP version.54
class _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;62
}64
/// A traceroute of one IP version.65
class _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;73
}75
const _presets = ['4', '10', '20', '50', '100', 'custom', 'continuous'];77
class DiagnosticsPage extends StatefulWidget {78
final AppController controller;79
final NetTools tools;80
DiagnosticsPage(this.controller, {NetTools? tools, super.key})81
: tools = tools ?? NetTools();82
@override83
State<DiagnosticsPage> createState() => _DiagnosticsPageState();84
}86
class _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
@override136
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
? 0205
: 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
chosen238
..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.available272
? pinned(f.family, f.addresses) ?? f.addresses.first273
: 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().isEmpty328
? null329
: _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; // stopped477
state.run = null;478
if (state.error == null) {479
state.end = state.lane.reached480
? 'traceReached'481
: state.lane.hops.values.any((h) => h.markers.isNotEmpty)482
? 'traceUnreachable'483
: state.lane.hops.isEmpty484
? null485
: '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.trace521
? 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
// Settings550
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: locked590
? null591
: (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: locked633
? null634
: (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: locked660
? null661
: (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: locked684
? null685
: (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.tcp727
? '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: locked740
? null741
: (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: locked749
? null750
: (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
else762
FilledButton.icon(763
onPressed: busy ? null : run,764
icon: busy765
? 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: running807
? null808
: (_) => setState(() => chosen[f.family] = a),809
),810
],811
),812
)813
else814
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
// Results828
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) => running858
? context.tr('statusRunning')859
: error != null860
? context.tr(error)861
: end != null862
? 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 is966
/// 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 == 0992
? '—'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 == 0997
? error998
: null,999
),1000
_cell(1001
l.series.stats.stats().mean == null1002
? '—'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
);1044
}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 ?? ''}';1099
}1100
final marker = p.status == ProbeStatus.unreachable1101
? unreachableMarker(p.icmpCode, v6: v6)1102
: '';1103
return '${formatMs(context, p.rttMs!)} ms${marker.isEmpty ? '' : ' $marker'}';1104
}1106
final changes = compare && t.previous != null1107
? {1108
for (final c in compareTraces(t.previous!, lane.snapshot()))1109
c.ttl: c,1110
}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.isEmpty1167
? '*'1168
: h.addresses.keys1169
.map(1170
(a) => names[a] == null1171
? a1172
: '${names[a]} ($a)',1173
)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(1200
[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
);1222
}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));1228
}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
);1236
}1238
@override1239
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
else1255
_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
);1291
}1292
return ListView(1293
padding: readablePadding(constraints, maxWidth: 900),1294
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,1295
children: [_settings(context), ...results],1296
);1297
},1298
);1299
}SHA-256
895d8317525841cd97df92929a63c2ede4d6b387f5f1791073d12266dc7b5531CH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.
SHA-256 للأرشيف
46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655