diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 8cce9588..54ae8bbc 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; @@ -10,6 +10,8 @@ import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; +import 'package:querya_desktop/core/util/querya_log.dart'; +import 'package:querya_desktop/shared/report_error.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; export 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; @@ -26,14 +28,12 @@ Future saveNewConnection( await LocalDb.instance.addConnection(row); return true; } catch (e, st) { - debugPrint('saveNewConnection: "${row.name}" failed: ' - '${e.runtimeType}: $e\n$st'); + const action = 'The connection was not saved'; if (context.mounted) { - showAppToast( - context: context, - message: 'The connection was not saved. $e', - variant: AppToastVariant.error, - ); + reportError(context, e, + stack: st, where: 'saveNewConnection', action: action); + } else { + QueryaLog.instance.error('saveNewConnection', action, error: e, stack: st); } return false; } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index c8524505..71a52380 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -90,6 +90,7 @@ import 'package:querya_desktop/features/connections/connection_creation_flow.dar import 'package:querya_desktop/features/connections/connection_databases_folder.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/features/connections/querya_connection_tree_row.dart'; +import 'package:querya_desktop/shared/report_error.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/app/app_shutdown.dart'; @@ -721,10 +722,11 @@ class ConnectionsPanelState extends State { } on SecretsStoreUnavailableException catch (e) { // Saving would write the secrets it could not read as empty ones. if (mounted) { - showAppToast( - context: context, - variant: AppToastVariant.error, - message: 'The connection was not saved. ${e.toString()}', + reportError( + context, + e, + where: 'ConnectionsPanel._editConnection', + action: 'The connection was not saved', ); } return; diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 3c0a4711..ad68a504 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/shared/report_error.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:flutter/widgets.dart'; import 'dart:async'; @@ -220,8 +222,8 @@ class _RedisConnectionFormContentState if (mounted) { _showTestResult(result.ok ? 'success' : (result.error ?? 'failed')); } - } catch (e) { - if (mounted) _showTestResult('error: $e'); + } catch (e, st) { + if (mounted) _showTestResult(failureMessage('error', e, where: 'RedisConnectionForm', stack: st, driver: DatabaseDriver.redis)); } } diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 7a33c449..37ca2cfc 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/shared/report_error.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; @@ -315,11 +317,11 @@ class _RedisKeyEditorState extends material.State { } if (!mounted) return; setState(() => _loadingMore = false); - } catch (e) { + } catch (e, st) { if (!mounted) return; setState(() { _loadingMore = false; - _error = 'Load failed: $e'; + _error = failureMessage('Load failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis); }); } } @@ -336,9 +338,9 @@ class _RedisKeyEditorState extends material.State { if (!mounted) return; setState(() => _success = 'Value saved'); _clearSuccessAfterDelay(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'Save failed: $e'); + setState(() => _error = failureMessage('Save failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -377,9 +379,9 @@ class _RedisKeyEditorState extends material.State { }); _clearSuccessAfterDelay(); widget.onKeyRenamed?.call(newBulk); - } catch (e) { + } catch (e, st) { if (mounted) { - setState(() => _error = 'Rename failed: $e'); + setState(() => _error = failureMessage('Rename failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } } @@ -397,9 +399,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.del(_cmdKey); widget.onKeyDeleted?.call(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'Delete failed: $e'); + setState(() => _error = failureMessage('Delete failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -417,9 +419,9 @@ class _RedisKeyEditorState extends material.State { _success = seconds > 0 ? 'TTL set to $seconds seconds' : 'TTL removed'; }); _clearSuccessAfterDelay(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'TTL failed: $e'); + setState(() => _error = failureMessage('TTL failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -429,9 +431,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.hset(_cmdKey, field, value); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'HSET failed: $e'); + setState(() => _error = failureMessage('HSET failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -448,9 +450,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.hdel(_cmdKey, field); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'HDEL failed: $e'); + setState(() => _error = failureMessage('HDEL failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -460,9 +462,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.rpush(_cmdKey, value); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'RPUSH failed: $e'); + setState(() => _error = failureMessage('RPUSH failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -485,9 +487,9 @@ class _RedisKeyEditorState extends material.State { } await widget.connection.lset(_cmdKey, index, newValue); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'LSET failed: $e'); + setState(() => _error = failureMessage('LSET failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -522,9 +524,9 @@ class _RedisKeyEditorState extends material.State { rethrow; } await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'LREM failed: $e'); + setState(() => _error = failureMessage('LREM failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -541,9 +543,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.sadd(_cmdKey, member); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'SADD failed: $e'); + setState(() => _error = failureMessage('SADD failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -560,9 +562,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.srem(_cmdKey, member); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'SREM failed: $e'); + setState(() => _error = failureMessage('SREM failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -572,9 +574,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.zadd(_cmdKey, score, member); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'ZADD failed: $e'); + setState(() => _error = failureMessage('ZADD failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } @@ -591,9 +593,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.zrem(_cmdKey, member); await _load(); - } catch (e) { + } catch (e, st) { if (!mounted) return; - setState(() => _error = 'ZREM failed: $e'); + setState(() => _error = failureMessage('ZREM failed', e, where: 'RedisKeyEditor', stack: st, driver: DatabaseDriver.redis)); } } diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index cd932867..e46d7900 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/shared/report_error.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; @@ -130,13 +132,13 @@ class _RedisKeysViewState extends material.State { ttl: metas[i].ttl, ), ]; - } catch (e) { + } catch (e, st) { // Still show keys with unknown type/TTL; surface the failure non-blocking. infos = [ for (final name in accumulatedKeys) RedisKeyInfo(name: name, type: 'unknown', ttl: -1), ]; - typeTtlError = 'Failed to load key types/TTLs: $e'; + typeTtlError = failureMessage('Failed to load key types/TTLs', e, where: 'RedisKeysView', stack: st, driver: DatabaseDriver.redis); } } else { infos = []; @@ -193,8 +195,8 @@ class _RedisKeysViewState extends material.State { _keys.removeWhere((k) => k.name == keyInfo.name); _dbSize = (_dbSize - 1).clamp(0, _dbSize); }); - } catch (e) { - if (mounted) setState(() => _error = 'Delete failed: $e'); + } catch (e, st) { + if (mounted) setState(() => _error = failureMessage('Delete failed', e, where: 'RedisKeysView', stack: st, driver: DatabaseDriver.redis)); } } @@ -222,11 +224,11 @@ class _RedisKeysViewState extends material.State { _keys.removeWhere((k) => toRemove.contains(k.name)); _dbSize = (_dbSize - count).clamp(0, _dbSize); }); - } catch (e) { + } catch (e, st) { if (!mounted) return; // Some chunks may have been removed: show what is really left. await _load(); - if (mounted) setState(() => _error = 'Delete folder failed: $e'); + if (mounted) setState(() => _error = failureMessage('Delete folder failed', e, where: 'RedisKeysView', stack: st, driver: DatabaseDriver.redis)); } } diff --git a/lib/shared/report_error.dart b/lib/shared/report_error.dart new file mode 100644 index 00000000..ccc073c9 --- /dev/null +++ b/lib/shared/report_error.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/mcp/mcp_redaction.dart'; +import 'package:querya_desktop/core/util/querya_log.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; + +/// The text a user reads for [error]: the mapped message with its hint for a +/// driver error, the error's own text otherwise, without credentials. +String describeError(Object error, {DatabaseDriver? driver}) => + McpRedaction.redact(describeDatabaseError(error, driver: driver)); + +/// The one way to tell the user that an action failed: writes the failure to +/// the log (with its stack) and shows an error toast, `. `. +/// +/// Use it for a failed user action. A lazy load that fails inside a tree uses +/// `TreeLoadError`, and a query uses `QueryErrorView`; they should still log +/// through [QueryaLog]. +/// +/// [action] is a short sentence without the final full stop (`'The connection +/// was not saved'`). [where] names the place for the log (`'saveNewConnection'`). +/// Safe to call when [context] has been unmounted: the log is still written and +/// the toast is skipped. +void reportError( + BuildContext context, + Object error, { + required String where, + required String action, + StackTrace? stack, + DatabaseDriver? driver, +}) { + QueryaLog.instance.error(where, action, error: error, stack: stack); + if (!context.mounted) return; + showAppToast( + context: context, + variant: AppToastVariant.error, + message: '$action. ${describeError(error, driver: driver)}', + ); +} + +/// Logs [error] and returns `: ` for an inline error line (a +/// view's own error text), where a toast is not the surface. [action] is a +/// short phrase (`'Delete failed'`); [where] names the place for the log. +String failureMessage( + String action, + Object error, { + required String where, + StackTrace? stack, + DatabaseDriver? driver, +}) { + QueryaLog.instance.error(where, action, error: error, stack: stack); + return '$action: ${describeError(error, driver: driver)}'; +} diff --git a/test/shared/report_error_test.dart b/test/shared/report_error_test.dart new file mode 100644 index 00000000..9c55a89b --- /dev/null +++ b/test/shared/report_error_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/util/querya_log.dart'; +import 'package:querya_desktop/shared/report_error.dart'; + +import '../support/querya_theme_test_shell.dart'; + +void main() { + late Directory logDir; + + /// For the plain tests only: inside `testWidgets` the log's file writes run + /// in the fake-async zone and would never complete. + Future startLog() async { + logDir = await Directory.systemTemp.createTemp('querya_report_error_'); + await QueryaLog.instance.init(logDir); + addTearDown(() => logDir.delete(recursive: true)); + } + + Future pumpHost(WidgetTester tester) async { + late material.BuildContext host; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) { + host = context; + return const material.SizedBox(); + }, + ), + ), + ); + return host; + } + + test('describeError keeps an unknown error as it is and hides credentials', () { + expect(describeError(StateError('boom')), 'Bad state: boom'); + expect( + describeError(StateError('failed for postgresql://admin:hunter22@db/x')), + isNot(contains('hunter22')), + ); + }); + + test('describeError maps a driver error to plain words', () { + final text = describeError( + const SocketException('Connection refused', osError: OSError('', 111)), + driver: DatabaseDriver.postgres, + ); + expect(text, contains('Cannot reach the database server')); + }); + + test('failureMessage logs and returns the inline error line', () async { + await startLog(); + final text = failureMessage( + 'Delete failed', + StateError('connection reset'), + where: 'RedisKeysView', + driver: DatabaseDriver.redis, + ); + await QueryaLog.instance.flush(); + + expect(text, startsWith('Delete failed: ')); + final log = + await File('${logDir.path}/${QueryaLog.fileName}').readAsString(); + expect(log, contains('[RedisKeysView] Delete failed')); + }); + + testWidgets('reportError shows the toast', (tester) async { + final host = await pumpHost(tester); + + reportError( + host, + StateError('keychain write failed'), + where: 'Somewhere.save', + action: 'The thing was not saved', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect( + find.text('The thing was not saved. Bad state: keychain write failed'), + findsOneWidget, + ); + + await tester.pump(const Duration(seconds: 6)); + }); +}