feat: add runtime diagnostics log viewer

This commit is contained in:
Haitao Pan 2026-03-12 01:15:50 +08:00
parent 7d53912463
commit f018bb151b
8 changed files with 545 additions and 13 deletions

View File

@ -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);
}

View File

@ -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,

View File

@ -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();

View File

@ -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,

View File

@ -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(

View File

@ -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);
});
}

View File

@ -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',

View File

@ -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);
},
);
}