mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add Dio timeouts and map backend errors to user-friendly messages
Set 5s/15s/15s connect/receive/send timeouts so calls fail fast instead of hanging. Introduce dioErrorToUserMessage() and route DioException-throwing call sites (device actions, devices list, notifications list, scanners status card) through it, replacing raw e.toString() output. Notifications list now surfaces load errors inline in the theme's error color, matching the devices list pattern. Status dots show the mapped detail as a tooltip. Stop logging the API key in cleartext. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0f6296759d
commit
7954a9c755
@@ -42,7 +42,10 @@ Future<void> confirmForgetDevice(
|
|||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
UISnackbars.showError(context, 'Failed to forget device: $e');
|
UISnackbars.showError(
|
||||||
|
context,
|
||||||
|
'Failed to forget device. ${dioErrorToUserMessage(e)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +160,10 @@ Future<void> showEditDeviceDialog(
|
|||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
UISnackbars.showError(context, 'Failed to update device: $e');
|
UISnackbars.showError(
|
||||||
|
context,
|
||||||
|
'Failed to update device. ${dioErrorToUserMessage(e)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +266,9 @@ Future<void> showRegisterDeviceDialog(
|
|||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
UISnackbars.showError(context, 'Failed to register device: $e');
|
UISnackbars.showError(
|
||||||
|
context,
|
||||||
|
'Failed to register device. ${dioErrorToUserMessage(e)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = dioErrorToUserMessage(e);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,12 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(child: Text('Error: $_error'));
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: $_error',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (_devices.isEmpty) {
|
if (_devices.isEmpty) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
|
|||||||
List<oott_model.Notification> _items = [];
|
List<oott_model.Notification> _items = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasNextPage = false;
|
bool _hasNextPage = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -78,10 +79,16 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _fetchPage(int page) async {
|
Future<void> _fetchPage(int page) async {
|
||||||
if (_isLoading) return;
|
if (_isLoading) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final results = await BackendAPI.instance
|
final results = await BackendAPI.instance.listNotifications(
|
||||||
.listNotifications(_filter.isNew, page * _pageSize, limit: _pageSize + 1);
|
_filter.isNew,
|
||||||
|
page * _pageSize,
|
||||||
|
limit: _pageSize + 1,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentPage = page;
|
_currentPage = page;
|
||||||
@@ -89,9 +96,12 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
|
|||||||
_items = _hasNextPage ? results.take(_pageSize).toList() : results;
|
_items = _hasNextPage ? results.take(_pageSize).toList() : results;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = false);
|
setState(() {
|
||||||
|
_error = dioErrorToUserMessage(e);
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +215,18 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return [
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: $_error',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
if (_items.isEmpty) {
|
if (_items.isEmpty) {
|
||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|||||||
@@ -12,6 +12,44 @@ import '../model/device_summary.dart';
|
|||||||
import '../model/device_type.dart';
|
import '../model/device_type.dart';
|
||||||
import '../model/notification.dart';
|
import '../model/notification.dart';
|
||||||
|
|
||||||
|
const _connectTimeout = Duration(seconds: 5);
|
||||||
|
const _receiveTimeout = Duration(seconds: 15);
|
||||||
|
const _sendTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
|
String dioErrorToUserMessage(Object error) {
|
||||||
|
if (error is! DioException) {
|
||||||
|
debugPrint('Non-Dio error from backend call: $error');
|
||||||
|
return 'Unexpected error.';
|
||||||
|
}
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return 'Backend did not respond in time.';
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return 'Cannot reach backend. Check the base URL and your network.';
|
||||||
|
case DioExceptionType.badCertificate:
|
||||||
|
return 'Backend TLS certificate could not be verified.';
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
return 'Request canceled.';
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
final status = error.response?.statusCode;
|
||||||
|
if (status == 401 || status == 403) {
|
||||||
|
return 'Authentication failed. Check your API key in Settings.';
|
||||||
|
}
|
||||||
|
if (status == 404) {
|
||||||
|
return 'Not found.';
|
||||||
|
}
|
||||||
|
if (status != null && status >= 500 && status < 600) {
|
||||||
|
return 'Backend error (status $status). Please try again.';
|
||||||
|
}
|
||||||
|
return 'Unexpected response from backend (status ${status ?? 'unknown'}).';
|
||||||
|
case DioExceptionType.unknown:
|
||||||
|
debugPrint('Unknown Dio error: ${error.message}');
|
||||||
|
return 'Unexpected error contacting backend.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BackendAPI {
|
class BackendAPI {
|
||||||
static final BackendAPI _instance = BackendAPI._internal();
|
static final BackendAPI _instance = BackendAPI._internal();
|
||||||
static const _pageSize = 5;
|
static const _pageSize = 5;
|
||||||
@@ -24,11 +62,14 @@ class BackendAPI {
|
|||||||
_apiKey = XOR().xorDecode(PrefUtil.getValue("api_key", "") as String);
|
_apiKey = XOR().xorDecode(PrefUtil.getValue("api_key", "") as String);
|
||||||
|
|
||||||
debugPrint('Base URL: $_baseUrl');
|
debugPrint('Base URL: $_baseUrl');
|
||||||
debugPrint('API KEY: $_apiKey');
|
debugPrint('API KEY: ${_apiKey.isEmpty ? "<empty>" : "<set>"}');
|
||||||
|
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: _baseUrl,
|
baseUrl: _baseUrl,
|
||||||
|
connectTimeout: _connectTimeout,
|
||||||
|
receiveTimeout: _receiveTimeout,
|
||||||
|
sendTimeout: _sendTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
HttpHeaders.contentTypeHeader: 'application/json',
|
HttpHeaders.contentTypeHeader: 'application/json',
|
||||||
HttpHeaders.authorizationHeader: 'Bearer $_apiKey',
|
HttpHeaders.authorizationHeader: 'Bearer $_apiKey',
|
||||||
@@ -39,10 +80,13 @@ class BackendAPI {
|
|||||||
|
|
||||||
// Returns null if the test was successful, and a String with a message about the issue if not
|
// Returns null if the test was successful, and a String with a message about the issue if not
|
||||||
static Future<String?> test(String baseUrl, String apiKey) async {
|
static Future<String?> test(String baseUrl, String apiKey) async {
|
||||||
debugPrint('About to test API with baseUrl=$baseUrl and apiKey=$apiKey');
|
debugPrint('About to test API with baseUrl=$baseUrl');
|
||||||
Dio dio = Dio(
|
Dio dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
|
connectTimeout: _connectTimeout,
|
||||||
|
receiveTimeout: _receiveTimeout,
|
||||||
|
sendTimeout: _sendTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
HttpHeaders.contentTypeHeader: 'application/json',
|
HttpHeaders.contentTypeHeader: 'application/json',
|
||||||
HttpHeaders.authorizationHeader: 'Bearer $apiKey',
|
HttpHeaders.authorizationHeader: 'Bearer $apiKey',
|
||||||
@@ -55,17 +99,8 @@ class BackendAPI {
|
|||||||
return response.toString().contains('OOTT_API_OK')
|
return response.toString().contains('OOTT_API_OK')
|
||||||
? null
|
? null
|
||||||
: "URL successfully called but didn't return the expected value. Check your URL and make sure it points to your OOTT backend base URL.";
|
: "URL successfully called but didn't return the expected value. Check your URL and make sure it points to your OOTT backend base URL.";
|
||||||
} on DioException catch (e) {
|
} catch (e) {
|
||||||
if (e.response != null) {
|
return dioErrorToUserMessage(e);
|
||||||
if (e.response!.statusCode == 401) {
|
|
||||||
return "Authorization failed, check your API Key.";
|
|
||||||
} else {
|
|
||||||
return "Error querying the given URL (${e.response!.statusCode} - ${e.message ?? 'N/A'})";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Response was null, something happened while sending the message
|
|
||||||
return "Error sending message to provided URL (${e.message ?? 'no message'})";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard> {
|
|||||||
try {
|
try {
|
||||||
arp = await BackendAPI.instance.getArpScannerStatus();
|
arp = await BackendAPI.instance.getArpScannerStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
arpError = e.toString();
|
arpError = dioErrorToUserMessage(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
MdnsScannerStatus? mdns;
|
MdnsScannerStatus? mdns;
|
||||||
@@ -56,7 +56,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard> {
|
|||||||
try {
|
try {
|
||||||
mdns = await BackendAPI.instance.getMdnsScannerStatus();
|
mdns = await BackendAPI.instance.getMdnsScannerStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mdnsError = e.toString();
|
mdnsError = dioErrorToUserMessage(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -99,9 +99,9 @@ class _ScannersStatusCardState extends State<ScannersStatusCard> {
|
|||||||
children: [
|
children: [
|
||||||
Text('Status', style: Theme.of(context).textTheme.titleMedium),
|
Text('Status', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_scannerRow(context, arpColor, 'ARP', arpText),
|
_scannerRow(context, arpColor, 'ARP', arpText, _arpError),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_scannerRow(context, mdnsColor, 'mDNS', mdnsText),
|
_scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdnsError),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -114,10 +114,15 @@ class _ScannersStatusCardState extends State<ScannersStatusCard> {
|
|||||||
Color color,
|
Color color,
|
||||||
String name,
|
String name,
|
||||||
String statusText,
|
String statusText,
|
||||||
|
String? errorMessage,
|
||||||
) {
|
) {
|
||||||
|
Widget dot = Icon(Icons.circle, color: color, size: 12);
|
||||||
|
if (errorMessage != null) {
|
||||||
|
dot = Tooltip(message: errorMessage, child: dot);
|
||||||
|
}
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.circle, color: color, size: 12),
|
dot,
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
|
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
|||||||
Reference in New Issue
Block a user