759 lines
22 KiB
Dart
759 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../../widgets/desktop_constrained.dart';
|
|
|
|
enum _NetworkTestTab { network, service, custom }
|
|
|
|
class NetworkTestPage extends StatefulWidget {
|
|
const NetworkTestPage({super.key});
|
|
|
|
@override
|
|
State<NetworkTestPage> createState() => _NetworkTestPageState();
|
|
}
|
|
|
|
class _NetworkTestPageState extends State<NetworkTestPage> {
|
|
static final _panUri = Uri.parse('https://pan.gongyun.org');
|
|
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
|
|
|
|
_NetworkTestTab _tab = _NetworkTestTab.network;
|
|
bool _networkLoading = false;
|
|
bool _serviceLoading = false;
|
|
_NetworkDiagnostics? _networkDiagnostics;
|
|
_ServiceDiagnostics? _serviceDiagnostics;
|
|
String? _networkError;
|
|
String? _serviceError;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('网络测试'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: '说明',
|
|
icon: const Icon(Icons.info_outline),
|
|
onPressed: () =>
|
|
launchUrl(_infoUri, mode: LaunchMode.externalApplication),
|
|
),
|
|
],
|
|
),
|
|
body: DesktopConstrained(
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: SegmentedButton<_NetworkTestTab>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: _NetworkTestTab.network,
|
|
icon: Icon(Icons.public),
|
|
label: Text('网络'),
|
|
),
|
|
ButtonSegment(
|
|
value: _NetworkTestTab.service,
|
|
icon: Icon(Icons.dns_outlined),
|
|
label: Text('服务'),
|
|
),
|
|
ButtonSegment(
|
|
value: _NetworkTestTab.custom,
|
|
icon: Icon(Icons.route_outlined),
|
|
label: Text('自定义'),
|
|
),
|
|
],
|
|
selected: {_tab},
|
|
onSelectionChanged: (selection) {
|
|
final selected = selection.first;
|
|
if (selected == _NetworkTestTab.custom) {
|
|
_showCustomUnavailableDialog();
|
|
return;
|
|
}
|
|
setState(() => _tab = selected);
|
|
if (selected == _NetworkTestTab.network &&
|
|
_networkDiagnostics == null &&
|
|
!_networkLoading) {
|
|
_runNetworkTest();
|
|
}
|
|
if (selected == _NetworkTestTab.service &&
|
|
_serviceDiagnostics == null &&
|
|
!_serviceLoading) {
|
|
_runServiceTest();
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
child: switch (_tab) {
|
|
_NetworkTestTab.network => _NetworkPanel(
|
|
key: const ValueKey('network'),
|
|
loading: _networkLoading,
|
|
error: _networkError,
|
|
diagnostics: _networkDiagnostics,
|
|
onRefresh: _runNetworkTest,
|
|
),
|
|
_NetworkTestTab.service => _ServicePanel(
|
|
key: const ValueKey('service'),
|
|
loading: _serviceLoading,
|
|
error: _serviceError,
|
|
diagnostics: _serviceDiagnostics,
|
|
onRefresh: _runServiceTest,
|
|
),
|
|
_NetworkTestTab.custom => const SizedBox.shrink(),
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showCustomUnavailableDialog() async {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('自定义线路'),
|
|
content: const Text('此功能暂不可用'),
|
|
actions: [
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('好'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _runNetworkTest() async {
|
|
setState(() {
|
|
_networkLoading = true;
|
|
_networkError = null;
|
|
});
|
|
|
|
try {
|
|
final ipFuture = _fetchIpInfo();
|
|
final panProbe = await _probeUri(_panUri);
|
|
final stability = await _testStability(_panUri);
|
|
final ipInfo = await ipFuture;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_networkDiagnostics = _NetworkDiagnostics(
|
|
panReachable: panProbe.reachable,
|
|
latencyMs: panProbe.latencyMs,
|
|
ipInfo: ipInfo,
|
|
stable: stability.stable,
|
|
sampleCount: stability.sampleCount,
|
|
successCount: stability.successCount,
|
|
);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) setState(() => _networkError = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _networkLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _runServiceTest() async {
|
|
setState(() {
|
|
_serviceLoading = true;
|
|
_serviceError = null;
|
|
});
|
|
|
|
try {
|
|
final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun');
|
|
|
|
_ServiceItem monitor(String label, int monitorId) {
|
|
return _monitorItem(
|
|
label: label,
|
|
monitorId: monitorId,
|
|
heartbeats: monitorHeartbeats,
|
|
);
|
|
}
|
|
|
|
final telegramBot = await _probeItem(
|
|
label: 'Telegram bot',
|
|
uri: Uri.parse('https://bot.mygongyun.com'),
|
|
healthyStatusCodes: const {200, 401},
|
|
);
|
|
|
|
final business = <_ServiceItem>[
|
|
monitor('官网', 3),
|
|
monitor('网盘', 2),
|
|
monitor('短链', 37),
|
|
monitor('SSO', 39),
|
|
telegramBot,
|
|
];
|
|
|
|
final dc = <_ServiceItem>[
|
|
monitor('DC3-CN-SZ', 13),
|
|
monitor('DC3-CN-MIAMI', 11),
|
|
monitor('DC3-CN-NY', 12),
|
|
monitor('DC3-CN-LV', 10),
|
|
];
|
|
|
|
final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)];
|
|
|
|
final storage = <_ServiceItem>[
|
|
monitor('DC-S-NA-1', 5),
|
|
monitor('DC-S-NA-2', 6),
|
|
monitor('DC-S-EU', 7),
|
|
monitor('DC-S-EP', 8),
|
|
];
|
|
|
|
final acceleration = <_ServiceItem>[
|
|
monitor('香港', 18),
|
|
monitor('香港', 19),
|
|
monitor('新加坡', 22),
|
|
monitor('洛杉矶', 24),
|
|
monitor('蒙特利尔', 21),
|
|
];
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_serviceDiagnostics = _ServiceDiagnostics(
|
|
fetchedAt: DateTime.now(),
|
|
business: business,
|
|
dc: dc,
|
|
backend: backend,
|
|
storage: storage,
|
|
acceleration: acceleration,
|
|
);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) setState(() => _serviceError = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _serviceLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<_IpInfo> _fetchIpInfo() async {
|
|
try {
|
|
final response = await http
|
|
.get(Uri.parse('https://ipwho.is/'))
|
|
.timeout(const Duration(seconds: 8));
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
if (data['success'] != false) {
|
|
final connection = data['connection'];
|
|
final connectionMap = connection is Map<String, dynamic>
|
|
? connection
|
|
: <String, dynamic>{};
|
|
return _IpInfo(
|
|
ip: data['ip']?.toString() ?? '未知',
|
|
asn: connectionMap['asn']?.toString() ?? '未知',
|
|
operatorName: connectionMap['isp']?.toString().isNotEmpty == true
|
|
? connectionMap['isp'].toString()
|
|
: (connectionMap['org']?.toString() ?? '未知'),
|
|
);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Fall back to the next public endpoint.
|
|
}
|
|
|
|
try {
|
|
final response = await http
|
|
.get(Uri.parse('https://ipapi.co/json/'))
|
|
.timeout(const Duration(seconds: 8));
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
return _IpInfo(
|
|
ip: data['ip']?.toString() ?? '未知',
|
|
asn: data['asn']?.toString() ?? '未知',
|
|
operatorName: data['org']?.toString() ?? '未知',
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Handled by the default value below.
|
|
}
|
|
|
|
return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知');
|
|
}
|
|
|
|
_ServiceItem _monitorItem({
|
|
required String label,
|
|
required int monitorId,
|
|
required Map<int, _MonitorHeartbeat> heartbeats,
|
|
}) {
|
|
final heartbeat = heartbeats[monitorId];
|
|
return _ServiceItem(
|
|
label: label,
|
|
online: heartbeat?.online ?? false,
|
|
latencyMs: heartbeat?.pingMs,
|
|
);
|
|
}
|
|
|
|
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
|
|
String pageId,
|
|
) async {
|
|
try {
|
|
final response = await http
|
|
.get(
|
|
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
|
|
headers: const {'Cache-Control': 'no-cache'},
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
return const {};
|
|
}
|
|
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! Map<String, dynamic>) return const {};
|
|
final data = decoded['data'];
|
|
if (data is! Map<String, dynamic>) return const {};
|
|
final heartbeatList = data['heartbeatList'];
|
|
if (heartbeatList is! Map<String, dynamic>) return const {};
|
|
|
|
final result = <int, _MonitorHeartbeat>{};
|
|
for (final entry in heartbeatList.entries) {
|
|
final monitorId = int.tryParse(entry.key);
|
|
final records = entry.value;
|
|
if (monitorId == null || records is! List || records.isEmpty) continue;
|
|
final latest = records.last;
|
|
if (latest is! Map<String, dynamic>) continue;
|
|
final status = _asInt(latest['status']);
|
|
final ping = _asInt(latest['ping']);
|
|
result[monitorId] = _MonitorHeartbeat(
|
|
online: status == 1,
|
|
pingMs: ping,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
} catch (_) {
|
|
return const {};
|
|
}
|
|
}
|
|
|
|
int? _asInt(Object? value) {
|
|
if (value is int) return value;
|
|
if (value is num) return value.round();
|
|
return int.tryParse(value?.toString() ?? '');
|
|
}
|
|
|
|
Future<_ServiceItem> _probeItem({
|
|
required String label,
|
|
required Uri uri,
|
|
Set<int> healthyStatusCodes = const {},
|
|
}) async {
|
|
final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes);
|
|
return _ServiceItem(
|
|
label: label,
|
|
online: probe.reachable,
|
|
latencyMs: probe.latencyMs,
|
|
);
|
|
}
|
|
|
|
Future<_ProbeResult> _probeUri(
|
|
Uri uri, {
|
|
Set<int> healthyStatusCodes = const {},
|
|
}) async {
|
|
final stopwatch = Stopwatch()..start();
|
|
try {
|
|
final response = await http
|
|
.get(uri, headers: const {'Cache-Control': 'no-cache'})
|
|
.timeout(const Duration(seconds: 8));
|
|
stopwatch.stop();
|
|
final isHealthy = healthyStatusCodes.isEmpty
|
|
? response.statusCode >= 200 && response.statusCode < 500
|
|
: healthyStatusCodes.contains(response.statusCode);
|
|
return _ProbeResult(
|
|
reachable: isHealthy,
|
|
latencyMs: stopwatch.elapsedMilliseconds,
|
|
);
|
|
} catch (_) {
|
|
stopwatch.stop();
|
|
return const _ProbeResult(reachable: false);
|
|
}
|
|
}
|
|
|
|
Future<_StabilityResult> _testStability(Uri uri) async {
|
|
final latencies = <int>[];
|
|
var successCount = 0;
|
|
const sampleCount = 3;
|
|
|
|
for (var i = 0; i < sampleCount; i++) {
|
|
final probe = await _probeUri(uri);
|
|
if (probe.reachable) {
|
|
successCount += 1;
|
|
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
|
|
}
|
|
if (i < sampleCount - 1) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 350));
|
|
}
|
|
}
|
|
|
|
final jitter = latencies.isEmpty
|
|
? 999999
|
|
: latencies.reduce(max) - latencies.reduce(min);
|
|
return _StabilityResult(
|
|
stable: successCount == sampleCount && jitter < 1500,
|
|
sampleCount: sampleCount,
|
|
successCount: successCount,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NetworkPanel extends StatelessWidget {
|
|
final bool loading;
|
|
final String? error;
|
|
final _NetworkDiagnostics? diagnostics;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _NetworkPanel({
|
|
super.key,
|
|
required this.loading,
|
|
required this.error,
|
|
required this.diagnostics,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = diagnostics;
|
|
final errorColor = Theme.of(context).colorScheme.error;
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
|
children: [
|
|
_SectionCard(
|
|
title: '网络',
|
|
children: [
|
|
if (loading) const LinearProgressIndicator(),
|
|
if (error != null) _ErrorText(error!),
|
|
_InfoRow(
|
|
label: '公云存储官网访问测试',
|
|
value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'),
|
|
valueColor: _statusColor(context, data?.panReachable),
|
|
),
|
|
_InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'),
|
|
_InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'),
|
|
_InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'),
|
|
_InfoRow(
|
|
label: '连接时延',
|
|
value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms',
|
|
),
|
|
_InfoRow(
|
|
label: '长期连接稳定性',
|
|
value: data == null
|
|
? '检测中'
|
|
: '${data.stable ? '稳定' : '不稳定'} '
|
|
'(${data.successCount}/${data.sampleCount})',
|
|
valueColor: _statusColor(context, data?.stable),
|
|
),
|
|
],
|
|
),
|
|
_SectionCard(
|
|
title: '说明',
|
|
children: const [
|
|
_DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'),
|
|
_DescriptionLine(
|
|
'由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象',
|
|
),
|
|
_DescriptionLine(
|
|
'如诺出现网络连接问题您可将本页面内容向公云存储团队进行报告,团队会根据相关诊断线索进行排查与优化。',
|
|
),
|
|
],
|
|
),
|
|
_SectionCard(
|
|
title: '警告',
|
|
titleColor: errorColor,
|
|
children: [
|
|
Text(
|
|
'本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!',
|
|
style: TextStyle(color: errorColor, fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
FilledButton.icon(
|
|
onPressed: loading ? null : onRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('重新检测'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ServicePanel extends StatelessWidget {
|
|
final bool loading;
|
|
final String? error;
|
|
final _ServiceDiagnostics? diagnostics;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _ServicePanel({
|
|
super.key,
|
|
required this.loading,
|
|
required this.error,
|
|
required this.diagnostics,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = diagnostics;
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
|
children: [
|
|
_SectionCard(
|
|
title: '业务状态',
|
|
children: [
|
|
if (loading) const LinearProgressIndicator(),
|
|
if (error != null) _ErrorText(error!),
|
|
..._itemsOrLoading(data?.business),
|
|
],
|
|
),
|
|
_SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)),
|
|
_SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)),
|
|
_SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)),
|
|
_SectionCard(
|
|
title: '加速线路',
|
|
children: _itemsOrLoading(data?.acceleration, showLatency: true),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FilledButton.icon(
|
|
onPressed: loading ? null : onRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('重新检测'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _itemsOrLoading(
|
|
List<_ServiceItem>? items, {
|
|
bool showLatency = false,
|
|
}) {
|
|
if (items == null) {
|
|
return const [_InfoRow(label: '状态', value: '检测中')];
|
|
}
|
|
return items
|
|
.map(
|
|
(item) => _InfoRow(
|
|
label: item.label,
|
|
value: showLatency
|
|
? item.latencyLabel
|
|
: (item.online ? '正常' : '离线'),
|
|
valueColor: item.online ? Colors.green : Colors.red,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
}
|
|
|
|
class _SectionCard extends StatelessWidget {
|
|
final String title;
|
|
final List<Widget> children;
|
|
final Color? titleColor;
|
|
|
|
const _SectionCard({
|
|
required this.title,
|
|
required this.children,
|
|
this.titleColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: titleColor,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
...children,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoRow extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final Color? valueColor;
|
|
|
|
const _InfoRow({required this.label, required this.value, this.valueColor});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
|
|
const SizedBox(width: 16),
|
|
Flexible(
|
|
child: Text(
|
|
value,
|
|
textAlign: TextAlign.right,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: valueColor,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DescriptionLine extends StatelessWidget {
|
|
final String text;
|
|
|
|
const _DescriptionLine(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Text(text),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorText extends StatelessWidget {
|
|
final String error;
|
|
|
|
const _ErrorText(this.error);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
'检测失败:$error',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color? _statusColor(BuildContext context, bool? value) {
|
|
if (value == null) return null;
|
|
return value ? Colors.green : Theme.of(context).colorScheme.error;
|
|
}
|
|
|
|
class _NetworkDiagnostics {
|
|
final bool panReachable;
|
|
final int? latencyMs;
|
|
final _IpInfo ipInfo;
|
|
final bool stable;
|
|
final int sampleCount;
|
|
final int successCount;
|
|
|
|
const _NetworkDiagnostics({
|
|
required this.panReachable,
|
|
required this.latencyMs,
|
|
required this.ipInfo,
|
|
required this.stable,
|
|
required this.sampleCount,
|
|
required this.successCount,
|
|
});
|
|
}
|
|
|
|
class _IpInfo {
|
|
final String ip;
|
|
final String asn;
|
|
final String operatorName;
|
|
|
|
const _IpInfo({
|
|
required this.ip,
|
|
required this.asn,
|
|
required this.operatorName,
|
|
});
|
|
}
|
|
|
|
class _ServiceDiagnostics {
|
|
final DateTime fetchedAt;
|
|
final List<_ServiceItem> business;
|
|
final List<_ServiceItem> dc;
|
|
final List<_ServiceItem> backend;
|
|
final List<_ServiceItem> storage;
|
|
final List<_ServiceItem> acceleration;
|
|
|
|
const _ServiceDiagnostics({
|
|
required this.fetchedAt,
|
|
required this.business,
|
|
required this.dc,
|
|
required this.backend,
|
|
required this.storage,
|
|
required this.acceleration,
|
|
});
|
|
}
|
|
|
|
class _ServiceItem {
|
|
final String label;
|
|
final bool online;
|
|
final int? latencyMs;
|
|
|
|
const _ServiceItem({
|
|
required this.label,
|
|
required this.online,
|
|
this.latencyMs,
|
|
});
|
|
|
|
String get latencyLabel {
|
|
if (!online) return '离线';
|
|
if (latencyMs == null) return '正常 / -- ms';
|
|
return '正常 / $latencyMs ms';
|
|
}
|
|
}
|
|
|
|
class _MonitorHeartbeat {
|
|
final bool online;
|
|
final int? pingMs;
|
|
|
|
const _MonitorHeartbeat({required this.online, this.pingMs});
|
|
}
|
|
|
|
class _ProbeResult {
|
|
final bool reachable;
|
|
final int? latencyMs;
|
|
|
|
const _ProbeResult({required this.reachable, this.latencyMs});
|
|
}
|
|
|
|
class _StabilityResult {
|
|
final bool stable;
|
|
final int sampleCount;
|
|
final int successCount;
|
|
|
|
const _StabilityResult({
|
|
required this.stable,
|
|
required this.sampleCount,
|
|
required this.successCount,
|
|
});
|
|
}
|