feat: add runtime diagnostics log viewer
This commit is contained in:
parent
7d53912463
commit
f018bb151b
@ -123,6 +123,7 @@ class AppController extends ChangeNotifier {
|
||||
List<SecretReferenceEntry> get secretReferences =>
|
||||
_settingsController.buildSecretReferences();
|
||||
List<SecretAuditEntry> get secretAuditTrail => _settingsController.auditTrail;
|
||||
List<RuntimeLogEntry> get runtimeLogs => _runtime.logs;
|
||||
|
||||
List<GatewayChatMessage> get chatMessages {
|
||||
final items = List<GatewayChatMessage>.from(_chatController.messages);
|
||||
@ -467,6 +468,10 @@ class AppController extends ChangeNotifier {
|
||||
return _settingsController.testVaultConnection();
|
||||
}
|
||||
|
||||
void clearRuntimeLogs() {
|
||||
_runtime.clearLogs();
|
||||
}
|
||||
|
||||
Future<ApisixYamlProfile> validateApisixYaml(ApisixYamlProfile profile) {
|
||||
return _settingsController.validateApisixYaml(profile);
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController _apisixYamlController;
|
||||
late final TextEditingController _vaultTokenController;
|
||||
late final TextEditingController _ollamaApiKeyController;
|
||||
late final TextEditingController _runtimeLogFilterController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -34,6 +35,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
_vaultTokenController = TextEditingController();
|
||||
_ollamaApiKeyController = TextEditingController();
|
||||
_runtimeLogFilterController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -41,6 +43,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_apisixYamlController.dispose();
|
||||
_vaultTokenController.dispose();
|
||||
_ollamaApiKeyController.dispose();
|
||||
_runtimeLogFilterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -673,6 +676,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
) {
|
||||
final runtimeLogs = controller.runtimeLogs
|
||||
.where(_matchesRuntimeLogFilter)
|
||||
.toList(growable: false)
|
||||
.reversed
|
||||
.toList(growable: false);
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
@ -697,6 +705,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
label: appText('代理', 'Agent'),
|
||||
value: controller.activeAgentName,
|
||||
),
|
||||
_InfoRow(
|
||||
label: appText('认证模式', 'Auth Mode'),
|
||||
value:
|
||||
controller.connection.connectAuthMode ??
|
||||
appText('未发起', 'Not attempted'),
|
||||
),
|
||||
_InfoRow(
|
||||
label: appText('认证诊断', 'Auth Diagnostics'),
|
||||
value: controller.connection.connectAuthSummary,
|
||||
),
|
||||
_InfoRow(
|
||||
label: appText('健康负载', 'Health Payload'),
|
||||
value: controller.connection.healthPayload == null
|
||||
@ -713,6 +731,93 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SurfaceCard(
|
||||
key: const ValueKey('runtime-log-card'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('运行日志', 'Runtime Logs'),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
appText(
|
||||
'只记录本机运行期的连接、鉴权、配对和 socket 诊断,不写入密钥明文。',
|
||||
'Shows local runtime diagnostics for connection, auth, pairing, and socket events without logging secret values.',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: runtimeLogs.isEmpty
|
||||
? null
|
||||
: () => controller.clearRuntimeLogs(),
|
||||
child: Text(appText('清空', 'Clear')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const ValueKey('runtime-log-filter'),
|
||||
controller: _runtimeLogFilterController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('筛选日志', 'Filter Logs'),
|
||||
hintText: appText(
|
||||
'按级别、分类或关键字过滤',
|
||||
'Filter by level, category, or keyword',
|
||||
),
|
||||
prefixIcon: const Icon(Icons.manage_search_rounded),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (runtimeLogs.isEmpty)
|
||||
Text(
|
||||
appText('当前没有运行日志。', 'No runtime logs yet.'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SelectionArea(
|
||||
child: ListView.separated(
|
||||
itemCount: runtimeLogs.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = runtimeLogs[index];
|
||||
return SelectableText(
|
||||
entry.line,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -822,6 +927,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return controller.saveSettings(snapshot);
|
||||
}
|
||||
|
||||
bool _matchesRuntimeLogFilter(RuntimeLogEntry entry) {
|
||||
final query = _runtimeLogFilterController.text.trim().toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final haystack = '${entry.level} ${entry.category} ${entry.message}'
|
||||
.toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}
|
||||
|
||||
Widget _buildDeviceSecurityCard(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
|
||||
@ -60,12 +60,14 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
StreamController<GatewayPushEvent>.broadcast();
|
||||
final Map<String, Completer<_RpcResponse>> _pending =
|
||||
<String, Completer<_RpcResponse>>{};
|
||||
final List<RuntimeLogEntry> _logs = <RuntimeLogEntry>[];
|
||||
|
||||
IOWebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _socketSubscription;
|
||||
Timer? _reconnectTimer;
|
||||
GatewayConnectionProfile? _desiredProfile;
|
||||
bool _manualDisconnect = false;
|
||||
bool _suppressReconnect = false;
|
||||
int _requestCounter = 0;
|
||||
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(
|
||||
@ -88,8 +90,26 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
RuntimePackageInfo get packageInfo => _packageInfo;
|
||||
RuntimeDeviceInfo get deviceInfo => _deviceInfo;
|
||||
Stream<GatewayPushEvent> get events => _events.stream;
|
||||
List<RuntimeLogEntry> get logs => List<RuntimeLogEntry>.unmodifiable(_logs);
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
void clearLogs() {
|
||||
if (_logs.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_logs.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void addRuntimeLogForTest({
|
||||
required String level,
|
||||
required String category,
|
||||
required String message,
|
||||
}) {
|
||||
_appendLog(level, category, message);
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
await _store.initialize();
|
||||
_packageInfo = await _loadPackageInfo();
|
||||
@ -104,6 +124,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
}) async {
|
||||
_desiredProfile = profile;
|
||||
_manualDisconnect = false;
|
||||
_suppressReconnect = false;
|
||||
await _closeSocket();
|
||||
|
||||
final endpoint = _resolveEndpoint(profile);
|
||||
@ -112,11 +133,25 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? '';
|
||||
final explicitToken = authTokenOverride.trim();
|
||||
final explicitPassword = authPasswordOverride.trim();
|
||||
final sharedTokenSource = explicitToken.isNotEmpty
|
||||
? 'shared:form'
|
||||
: storedToken.isNotEmpty
|
||||
? 'shared:store'
|
||||
: (setupPayload?.token.trim().isNotEmpty ?? false)
|
||||
? 'shared:setup-code'
|
||||
: null;
|
||||
final sharedToken = explicitToken.isNotEmpty
|
||||
? explicitToken
|
||||
: storedToken.isNotEmpty
|
||||
? storedToken
|
||||
: (setupPayload?.token.trim() ?? '');
|
||||
final passwordSource = explicitPassword.isNotEmpty
|
||||
? 'password:form'
|
||||
: storedPassword.isNotEmpty
|
||||
? 'password:store'
|
||||
: (setupPayload?.password.trim().isNotEmpty ?? false)
|
||||
? 'password:setup-code'
|
||||
: null;
|
||||
final password = explicitPassword.isNotEmpty
|
||||
? explicitPassword
|
||||
: storedPassword.isNotEmpty
|
||||
@ -130,25 +165,66 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
))?.trim() ??
|
||||
'';
|
||||
final explicitDeviceToken = '';
|
||||
final deviceTokenSource = explicitDeviceToken.isNotEmpty
|
||||
? 'device:form'
|
||||
: sharedToken.isEmpty && storedDeviceToken.isNotEmpty
|
||||
? 'device:store'
|
||||
: null;
|
||||
final deviceToken = explicitDeviceToken.isNotEmpty
|
||||
? explicitDeviceToken
|
||||
: sharedToken.isEmpty
|
||||
? storedDeviceToken
|
||||
: '';
|
||||
final authToken = sharedToken.isNotEmpty ? sharedToken : deviceToken;
|
||||
final connectAuthMode = sharedToken.isNotEmpty
|
||||
? 'shared-token'
|
||||
: deviceToken.isNotEmpty
|
||||
? 'device-token'
|
||||
: password.isNotEmpty
|
||||
? 'password'
|
||||
: 'none';
|
||||
final connectAuthFields = <String>[
|
||||
if (authToken.isNotEmpty) 'token',
|
||||
if (deviceToken.isNotEmpty) 'deviceToken',
|
||||
if (password.isNotEmpty) 'password',
|
||||
];
|
||||
final connectAuthSources = <String>[
|
||||
...?sharedTokenSource == null ? null : <String>[sharedTokenSource],
|
||||
...?deviceTokenSource == null ? null : <String>[deviceTokenSource],
|
||||
...?passwordSource == null ? null : <String>[passwordSource],
|
||||
];
|
||||
final connectAuthSummary = _connectAuthSummary(
|
||||
mode: connectAuthMode,
|
||||
fields: connectAuthFields,
|
||||
sources: connectAuthSources,
|
||||
);
|
||||
|
||||
if (endpoint == null) {
|
||||
_appendLog(
|
||||
'warn',
|
||||
'connect',
|
||||
'missing endpoint | auth: $connectAuthSummary',
|
||||
);
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode)
|
||||
.copyWith(
|
||||
statusText: 'Missing gateway endpoint',
|
||||
lastError: 'Configure setup code or manual host / port first.',
|
||||
lastErrorCode: 'MISSING_ENDPOINT',
|
||||
deviceId: identity.deviceId,
|
||||
connectAuthMode: connectAuthMode,
|
||||
connectAuthFields: connectAuthFields,
|
||||
connectAuthSources: connectAuthSources,
|
||||
);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_appendLog(
|
||||
'info',
|
||||
'connect',
|
||||
'attempt ${endpoint.$1}:${endpoint.$2} tls:${endpoint.$3} | auth: $connectAuthSummary',
|
||||
);
|
||||
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connecting,
|
||||
statusText: 'Connecting…',
|
||||
@ -156,6 +232,9 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
deviceId: identity.deviceId,
|
||||
authRole: 'operator',
|
||||
authScopes: kDefaultOperatorConnectScopes,
|
||||
connectAuthMode: connectAuthMode,
|
||||
connectAuthFields: connectAuthFields,
|
||||
connectAuthSources: connectAuthSources,
|
||||
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
|
||||
hasDeviceToken: deviceToken.isNotEmpty,
|
||||
clearLastError: true,
|
||||
@ -215,7 +294,14 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
role: stringValue(auth['role']) ?? 'operator',
|
||||
token: returnedDeviceToken,
|
||||
);
|
||||
_appendLog(
|
||||
'info',
|
||||
'auth',
|
||||
'stored device token for role ${stringValue(auth['role']) ?? 'operator'}',
|
||||
);
|
||||
}
|
||||
final negotiatedRole = stringValue(auth['role']) ?? 'operator';
|
||||
final negotiatedScopes = stringList(auth['scopes']);
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
@ -224,8 +310,11 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
mainSessionKey:
|
||||
stringValue(sessionDefaults['mainSessionKey']) ?? 'main',
|
||||
lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
authRole: stringValue(auth['role']) ?? 'operator',
|
||||
authScopes: stringList(auth['scopes']),
|
||||
authRole: negotiatedRole,
|
||||
authScopes: negotiatedScopes,
|
||||
connectAuthMode: connectAuthMode,
|
||||
connectAuthFields: connectAuthFields,
|
||||
connectAuthSources: connectAuthSources,
|
||||
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
|
||||
hasDeviceToken:
|
||||
(returnedDeviceToken != null && returnedDeviceToken.isNotEmpty) ||
|
||||
@ -234,6 +323,11 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
clearLastErrorCode: true,
|
||||
clearLastErrorDetailCode: true,
|
||||
);
|
||||
_appendLog(
|
||||
'info',
|
||||
'connect',
|
||||
'connected ${endpoint.$1}:${endpoint.$2} | role: $negotiatedRole | scopes: ${negotiatedScopes.length}',
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
final runtimeError = error is GatewayRuntimeException ? error : null;
|
||||
@ -245,18 +339,39 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
role: 'operator',
|
||||
);
|
||||
}
|
||||
if (!_shouldAutoReconnect(runtimeError)) {
|
||||
_suppressReconnect = true;
|
||||
_appendLog(
|
||||
'warn',
|
||||
'socket',
|
||||
'auto reconnect suppressed | code: ${runtimeError?.code ?? 'unknown'} | detail: ${runtimeError?.detailCode ?? 'none'}',
|
||||
);
|
||||
}
|
||||
await _closeSocket();
|
||||
_appendLog(
|
||||
'error',
|
||||
'connect',
|
||||
'failed ${endpoint.$1}:${endpoint.$2} | code: ${runtimeError?.code ?? 'unknown'} | detail: ${runtimeError?.detailCode ?? 'none'} | message: ${error.toString()}',
|
||||
);
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: error.toString(),
|
||||
lastErrorCode: runtimeError?.code,
|
||||
lastErrorDetailCode: runtimeError?.detailCode,
|
||||
connectAuthMode: connectAuthMode,
|
||||
connectAuthFields: connectAuthFields,
|
||||
connectAuthSources: connectAuthSources,
|
||||
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
|
||||
hasDeviceToken: deviceToken.isNotEmpty,
|
||||
);
|
||||
notifyListeners();
|
||||
if (_shouldAutoReconnect(runtimeError)) {
|
||||
_appendLog(
|
||||
'warn',
|
||||
'socket',
|
||||
'scheduling reconnect in 2s | code: ${runtimeError?.code ?? 'unknown'}',
|
||||
);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
rethrow;
|
||||
@ -265,6 +380,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
|
||||
Future<void> disconnect({bool clearDesiredProfile = true}) async {
|
||||
_manualDisconnect = true;
|
||||
_appendLog('info', 'connect', 'manual disconnect');
|
||||
if (clearDesiredProfile) {
|
||||
_desiredProfile = null;
|
||||
}
|
||||
@ -285,6 +401,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
Future<Map<String, dynamic>> health() async {
|
||||
final payload = asMap(await request('health'));
|
||||
_snapshot = _snapshot.copyWith(healthPayload: payload);
|
||||
_appendLog('debug', 'health', 'health snapshot refreshed');
|
||||
notifyListeners();
|
||||
return payload;
|
||||
}
|
||||
@ -292,6 +409,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
Future<Map<String, dynamic>> status() async {
|
||||
final payload = asMap(await request('status'));
|
||||
_snapshot = _snapshot.copyWith(statusPayload: payload);
|
||||
_appendLog('debug', 'health', 'status snapshot refreshed');
|
||||
notifyListeners();
|
||||
return payload;
|
||||
}
|
||||
@ -676,6 +794,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<GatewayPairedDevice?> approveDevicePairing(String requestId) async {
|
||||
_appendLog('info', 'pairing', 'approve request $requestId');
|
||||
final payload = asMap(
|
||||
await request(
|
||||
'device.pair.approve',
|
||||
@ -692,6 +811,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> rejectDevicePairing(String requestId) async {
|
||||
_appendLog('info', 'pairing', 'reject request $requestId');
|
||||
await request(
|
||||
'device.pair.reject',
|
||||
params: <String, dynamic>{'requestId': requestId},
|
||||
@ -700,6 +820,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> removePairedDevice(String deviceId) async {
|
||||
_appendLog('info', 'pairing', 'remove device $deviceId');
|
||||
await request(
|
||||
'device.pair.remove',
|
||||
params: <String, dynamic>{'deviceId': deviceId},
|
||||
@ -712,6 +833,11 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
required String role,
|
||||
List<String> scopes = const <String>[],
|
||||
}) async {
|
||||
_appendLog(
|
||||
'info',
|
||||
'token',
|
||||
'rotate role token | device: $deviceId | role: $role',
|
||||
);
|
||||
final payload = asMap(
|
||||
await request(
|
||||
'device.token.rotate',
|
||||
@ -742,6 +868,11 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
required String deviceId,
|
||||
required String role,
|
||||
}) async {
|
||||
_appendLog(
|
||||
'info',
|
||||
'token',
|
||||
'revoke role token | device: $deviceId | role: $role',
|
||||
);
|
||||
await request(
|
||||
'device.token.revoke',
|
||||
params: <String, dynamic>{'deviceId': deviceId, 'role': role},
|
||||
@ -759,6 +890,7 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
if (_channel == null || !isConnected) {
|
||||
_appendLog('warn', 'rpc', 'blocked request $method | offline');
|
||||
throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE');
|
||||
}
|
||||
final result = await _requestRaw(method, params: params, timeout: timeout);
|
||||
@ -942,11 +1074,23 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
if (nonce != null && !challenge.isCompleted) {
|
||||
challenge.complete(nonce);
|
||||
}
|
||||
_appendLog('debug', 'connect', 'challenge received');
|
||||
return;
|
||||
}
|
||||
if (event == 'health') {
|
||||
_snapshot = _snapshot.copyWith(healthPayload: asMap(payload));
|
||||
_appendLog('debug', 'health', 'push health update');
|
||||
notifyListeners();
|
||||
} else if (event == 'device.pair.requested' ||
|
||||
event == 'device.pair.resolved') {
|
||||
final eventPayload = asMap(payload);
|
||||
_appendLog(
|
||||
'info',
|
||||
'pairing',
|
||||
'$event | request: ${stringValue(eventPayload['requestId']) ?? 'unknown'} | device: ${stringValue(eventPayload['deviceId']) ?? 'unknown'}',
|
||||
);
|
||||
} else if (event == 'seqGap') {
|
||||
_appendLog('warn', 'sync', 'sequence gap detected');
|
||||
}
|
||||
_events.add(
|
||||
GatewayPushEvent(
|
||||
@ -972,6 +1116,17 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
final payload = decoded['payload'];
|
||||
final error = asMap(decoded['error']);
|
||||
if (!ok) {
|
||||
_appendLog(
|
||||
'error',
|
||||
'rpc',
|
||||
'request failed | code: ${stringValue(error['code']) ?? 'unknown'} | detail: ${stringValue(asMap(error['details'])['code']) ?? 'none'} | message: ${stringValue(error['message']) ?? 'gateway request failed'}',
|
||||
);
|
||||
if (!_shouldAutoReconnectForCodes(
|
||||
stringValue(error['code']),
|
||||
stringValue(asMap(error['details'])['code']),
|
||||
)) {
|
||||
_suppressReconnect = true;
|
||||
}
|
||||
completer.completeError(
|
||||
GatewayRuntimeException(
|
||||
stringValue(error['message']) ?? 'gateway request failed',
|
||||
@ -986,9 +1141,15 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
|
||||
void _handleSocketFailure(String message) {
|
||||
_failPending(GatewayRuntimeException(message, code: 'SOCKET_FAILURE'));
|
||||
if (_manualDisconnect) {
|
||||
if (_manualDisconnect || _suppressReconnect) {
|
||||
_appendLog(
|
||||
'warn',
|
||||
'socket',
|
||||
'failure ignored for reconnect | manual: $_manualDisconnect | suppressed: $_suppressReconnect | message: $message',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_appendLog('error', 'socket', 'failure | $message');
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Gateway error',
|
||||
@ -1004,9 +1165,15 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
_failPending(
|
||||
GatewayRuntimeException('socket closed', code: 'SOCKET_CLOSED'),
|
||||
);
|
||||
if (_manualDisconnect) {
|
||||
if (_manualDisconnect || _suppressReconnect) {
|
||||
_appendLog(
|
||||
'warn',
|
||||
'socket',
|
||||
'closed without reconnect | manual: $_manualDisconnect | suppressed: $_suppressReconnect',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_appendLog('warn', 'socket', 'closed by gateway');
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Disconnected',
|
||||
@ -1030,21 +1197,27 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
|
||||
void _scheduleReconnect() {
|
||||
final profile = _desiredProfile;
|
||||
if (_manualDisconnect || profile == null) {
|
||||
if (_manualDisconnect || _suppressReconnect || profile == null) {
|
||||
return;
|
||||
}
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
||||
_appendLog(
|
||||
'info',
|
||||
'socket',
|
||||
'reconnect firing | host: ${profile.host.trim().isEmpty ? 'setup-code' : profile.host.trim()} | port: ${profile.port}',
|
||||
);
|
||||
unawaited(connectProfile(profile));
|
||||
});
|
||||
}
|
||||
|
||||
bool _shouldAutoReconnect(GatewayRuntimeException? error) {
|
||||
if (error == null) {
|
||||
return true;
|
||||
}
|
||||
final code = error.code?.trim().toUpperCase();
|
||||
final detailCode = error.detailCode?.trim().toUpperCase();
|
||||
return _shouldAutoReconnectForCodes(error?.code, error?.detailCode);
|
||||
}
|
||||
|
||||
bool _shouldAutoReconnectForCodes(String? code, String? detailCode) {
|
||||
final resolvedCode = code?.trim().toUpperCase();
|
||||
final resolvedDetailCode = detailCode?.trim().toUpperCase();
|
||||
const nonRetryableCodes = <String>{
|
||||
'INVALID_REQUEST',
|
||||
'UNAUTHORIZED',
|
||||
@ -1063,10 +1236,11 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
'DEVICE_IDENTITY_REQUIRED',
|
||||
'CONTROL_UI_DEVICE_IDENTITY_REQUIRED',
|
||||
};
|
||||
if (code != null && nonRetryableCodes.contains(code)) {
|
||||
if (resolvedCode != null && nonRetryableCodes.contains(resolvedCode)) {
|
||||
return false;
|
||||
}
|
||||
if (detailCode != null && nonRetryableDetailCodes.contains(detailCode)) {
|
||||
if (resolvedDetailCode != null &&
|
||||
nonRetryableDetailCodes.contains(resolvedDetailCode)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -1082,6 +1256,32 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
_failPending(GatewayRuntimeException('socket reset', code: 'SOCKET_RESET'));
|
||||
}
|
||||
|
||||
void _appendLog(String level, String category, String message) {
|
||||
_logs.add(
|
||||
RuntimeLogEntry(
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||
level: level,
|
||||
category: category,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
const maxLogEntries = 250;
|
||||
if (_logs.length > maxLogEntries) {
|
||||
_logs.removeRange(0, _logs.length - maxLogEntries);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _connectAuthSummary({
|
||||
required String mode,
|
||||
required List<String> fields,
|
||||
required List<String> sources,
|
||||
}) {
|
||||
final resolvedFields = fields.isEmpty ? 'none' : fields.join(', ');
|
||||
final resolvedSources = sources.isEmpty ? 'none' : sources.join(' · ');
|
||||
return '$mode | fields: $resolvedFields | sources: $resolvedSources';
|
||||
}
|
||||
|
||||
void _failPending(Object error) {
|
||||
final values = _pending.values.toList(growable: false);
|
||||
_pending.clear();
|
||||
|
||||
@ -646,6 +646,9 @@ class GatewayConnectionSnapshot {
|
||||
required this.deviceId,
|
||||
required this.authRole,
|
||||
required this.authScopes,
|
||||
required this.connectAuthMode,
|
||||
required this.connectAuthFields,
|
||||
required this.connectAuthSources,
|
||||
required this.hasSharedAuth,
|
||||
required this.hasDeviceToken,
|
||||
required this.healthPayload,
|
||||
@ -665,6 +668,9 @@ class GatewayConnectionSnapshot {
|
||||
final String? deviceId;
|
||||
final String? authRole;
|
||||
final List<String> authScopes;
|
||||
final String? connectAuthMode;
|
||||
final List<String> connectAuthFields;
|
||||
final List<String> connectAuthSources;
|
||||
final bool hasSharedAuth;
|
||||
final bool hasDeviceToken;
|
||||
final Map<String, dynamic>? healthPayload;
|
||||
@ -687,6 +693,9 @@ class GatewayConnectionSnapshot {
|
||||
deviceId: null,
|
||||
authRole: null,
|
||||
authScopes: const <String>[],
|
||||
connectAuthMode: null,
|
||||
connectAuthFields: const <String>[],
|
||||
connectAuthSources: const <String>[],
|
||||
hasSharedAuth: false,
|
||||
hasDeviceToken: false,
|
||||
healthPayload: null,
|
||||
@ -708,6 +717,9 @@ class GatewayConnectionSnapshot {
|
||||
String? deviceId,
|
||||
String? authRole,
|
||||
List<String>? authScopes,
|
||||
String? connectAuthMode,
|
||||
List<String>? connectAuthFields,
|
||||
List<String>? connectAuthSources,
|
||||
bool? hasSharedAuth,
|
||||
bool? hasDeviceToken,
|
||||
Map<String, dynamic>? healthPayload,
|
||||
@ -741,6 +753,9 @@ class GatewayConnectionSnapshot {
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
authRole: authRole ?? this.authRole,
|
||||
authScopes: authScopes ?? this.authScopes,
|
||||
connectAuthMode: connectAuthMode ?? this.connectAuthMode,
|
||||
connectAuthFields: connectAuthFields ?? this.connectAuthFields,
|
||||
connectAuthSources: connectAuthSources ?? this.connectAuthSources,
|
||||
hasSharedAuth: hasSharedAuth ?? this.hasSharedAuth,
|
||||
hasDeviceToken: hasDeviceToken ?? this.hasDeviceToken,
|
||||
healthPayload: healthPayload ?? this.healthPayload,
|
||||
@ -764,6 +779,17 @@ class GatewayConnectionSnapshot {
|
||||
return detailCode == 'AUTH_TOKEN_MISSING' ||
|
||||
errorText.contains('gateway token missing');
|
||||
}
|
||||
|
||||
String get connectAuthSummary {
|
||||
final mode = connectAuthMode?.trim() ?? 'none';
|
||||
final fields = connectAuthFields.isEmpty
|
||||
? 'none'
|
||||
: connectAuthFields.join(', ');
|
||||
final sources = connectAuthSources.isEmpty
|
||||
? 'none'
|
||||
: connectAuthSources.join(' · ');
|
||||
return '$mode | fields: $fields | sources: $sources';
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimePackageInfo {
|
||||
@ -802,6 +828,27 @@ class RuntimeDeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeLogEntry {
|
||||
const RuntimeLogEntry({
|
||||
required this.timestampMs,
|
||||
required this.level,
|
||||
required this.category,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final int timestampMs;
|
||||
final String level;
|
||||
final String category;
|
||||
final String message;
|
||||
|
||||
String get timeLabel {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestampMs);
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String get line => '[$timeLabel] ${level.toUpperCase()} $category $message';
|
||||
}
|
||||
|
||||
class GatewayAgentSummary {
|
||||
const GatewayAgentSummary({
|
||||
required this.id,
|
||||
|
||||
@ -320,6 +320,13 @@ class _StatusBanner extends StatelessWidget {
|
||||
connection.remoteAddress ?? 'No active gateway target',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText('认证诊断', 'Auth Diagnostics'),
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(connection.connectAuthSummary, style: theme.textTheme.bodySmall),
|
||||
if (connection.pairingRequired) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
||||
@ -40,4 +40,43 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
controller.runtime.addRuntimeLogForTest(
|
||||
level: 'info',
|
||||
category: 'connect',
|
||||
message: 'connected remote gateway',
|
||||
);
|
||||
controller.runtime.addRuntimeLogForTest(
|
||||
level: 'warn',
|
||||
category: 'pairing',
|
||||
message: 'pairing required',
|
||||
);
|
||||
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
|
||||
await tester.tap(find.text('诊断'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('runtime-log-card')), findsOneWidget);
|
||||
expect(find.textContaining('connected remote gateway'), findsOneWidget);
|
||||
expect(find.textContaining('pairing required'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('runtime-log-filter')),
|
||||
'pairing',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('connected remote gateway'), findsNothing);
|
||||
expect(find.textContaining('pairing required'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('清空'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('当前没有运行日志。'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -37,6 +37,23 @@ void main() {
|
||||
expect(server.connectAuth?['token'], 'shared-token-from-form');
|
||||
expect(server.connectAuth?['deviceToken'], isNull);
|
||||
expect(runtime.snapshot.status, RuntimeConnectionStatus.connected);
|
||||
expect(runtime.snapshot.connectAuthMode, 'shared-token');
|
||||
expect(runtime.snapshot.connectAuthFields, const <String>['token']);
|
||||
expect(runtime.snapshot.connectAuthSources, const <String>[
|
||||
'shared:form',
|
||||
]);
|
||||
expect(
|
||||
runtime.logs.any(
|
||||
(entry) => entry.message.contains('shared-token-from-form'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
runtime.logs.any(
|
||||
(entry) => entry.message.contains('auth: shared-token'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -74,6 +91,14 @@ void main() {
|
||||
expect(server.connectAuth?['deviceToken'], 'stored-device-token');
|
||||
expect(runtime.snapshot.hasDeviceToken, isTrue);
|
||||
expect(runtime.snapshot.deviceId, identity.deviceId);
|
||||
expect(runtime.snapshot.connectAuthMode, 'device-token');
|
||||
expect(runtime.snapshot.connectAuthFields, const <String>[
|
||||
'token',
|
||||
'deviceToken',
|
||||
]);
|
||||
expect(runtime.snapshot.connectAuthSources, const <String>[
|
||||
'device:store',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@ -137,24 +162,91 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'GatewayRuntime does not auto reconnect after non-retryable pairing errors',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = SecureConfigStore();
|
||||
final runtime = GatewayRuntime(
|
||||
store: store,
|
||||
identityStore: DeviceIdentityStore(store),
|
||||
);
|
||||
final server = await _FakeGatewayRuntimeServer.start(
|
||||
connectErrorCode: 'INVALID_REQUEST',
|
||||
connectErrorDetailCode: 'PAIRING_REQUIRED',
|
||||
connectErrorMessage: 'pairing required',
|
||||
closeAfterConnectError: true,
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
addTearDown(server.close);
|
||||
|
||||
await expectLater(
|
||||
() => runtime.connectProfile(
|
||||
GatewayConnectionProfile.defaults().copyWith(
|
||||
mode: RuntimeConnectionMode.local,
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
tls: false,
|
||||
useSetupCode: false,
|
||||
),
|
||||
authTokenOverride: 'shared-token-from-form',
|
||||
),
|
||||
throwsA(isA<GatewayRuntimeException>()),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2400));
|
||||
|
||||
expect(server.connectRequestCount, 1);
|
||||
expect(runtime.snapshot.pairingRequired, isTrue);
|
||||
expect(
|
||||
runtime.logs.any(
|
||||
(entry) =>
|
||||
entry.category == 'socket' &&
|
||||
entry.message.contains('auto reconnect suppressed'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntimeServer {
|
||||
_FakeGatewayRuntimeServer._(this._server, {required this.currentDeviceId});
|
||||
_FakeGatewayRuntimeServer._(
|
||||
this._server, {
|
||||
required this.currentDeviceId,
|
||||
required this.connectErrorCode,
|
||||
required this.connectErrorDetailCode,
|
||||
required this.connectErrorMessage,
|
||||
required this.closeAfterConnectError,
|
||||
});
|
||||
|
||||
final HttpServer _server;
|
||||
final String? currentDeviceId;
|
||||
final String? connectErrorCode;
|
||||
final String? connectErrorDetailCode;
|
||||
final String? connectErrorMessage;
|
||||
final bool closeAfterConnectError;
|
||||
Map<String, dynamic>? connectAuth;
|
||||
int connectRequestCount = 0;
|
||||
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_FakeGatewayRuntimeServer> start({
|
||||
String? currentDeviceId,
|
||||
String? connectErrorCode,
|
||||
String? connectErrorDetailCode,
|
||||
String? connectErrorMessage,
|
||||
bool closeAfterConnectError = false,
|
||||
}) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = _FakeGatewayRuntimeServer._(
|
||||
server,
|
||||
currentDeviceId: currentDeviceId,
|
||||
connectErrorCode: connectErrorCode,
|
||||
connectErrorDetailCode: connectErrorDetailCode,
|
||||
connectErrorMessage: connectErrorMessage,
|
||||
closeAfterConnectError: closeAfterConnectError,
|
||||
);
|
||||
unawaited(fake._serve());
|
||||
return fake;
|
||||
@ -187,9 +279,34 @@ class _FakeGatewayRuntimeServer {
|
||||
const <String, dynamic>{};
|
||||
switch (method) {
|
||||
case 'connect':
|
||||
connectRequestCount += 1;
|
||||
connectAuth =
|
||||
(params['auth'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
if (connectErrorCode != null) {
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': false,
|
||||
'error': <String, dynamic>{
|
||||
'code': connectErrorCode,
|
||||
'message': connectErrorMessage ?? 'connect failed',
|
||||
'details': <String, dynamic>{
|
||||
if (connectErrorDetailCode != null)
|
||||
'code': connectErrorDetailCode,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (closeAfterConnectError) {
|
||||
await socket.close(
|
||||
WebSocketStatus.policyViolation,
|
||||
'connect failed',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'type': 'res',
|
||||
|
||||
@ -25,6 +25,8 @@ void main() {
|
||||
expect(find.text('端口'), findsOneWidget);
|
||||
expect(find.text('TLS'), findsOneWidget);
|
||||
expect(find.text('共享 Token'), findsOneWidget);
|
||||
expect(find.text('认证诊断'), findsOneWidget);
|
||||
expect(find.textContaining('fields: none'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user