From ec032c0a6d8200e4af0ffbe12a4bd13dc05b4830 Mon Sep 17 00:00:00 2001 From: "David B. Adrian" Date: Fri, 3 Jan 2025 16:41:52 +0100 Subject: [PATCH 1/3] update docs on nginx config --- docs/docs/setup/hetzner.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/docs/setup/hetzner.md b/docs/docs/setup/hetzner.md index 60a8486d..1b3783ef 100644 --- a/docs/docs/setup/hetzner.md +++ b/docs/docs/setup/hetzner.md @@ -249,6 +249,11 @@ http { } location / { + # check if backend is alive + auth_request /api/v1/info; # should yield 2xx http status code + + error_page 500 =503 @status_offline; + # checks for static file, if not found proxy to app try_files $uri @proxy_to_app; } From 582ff5dbafb94ceeda43985cbb6ebf71df890c1d Mon Sep 17 00:00:00 2001 From: "David B. Adrian" Date: Fri, 3 Jan 2025 16:44:08 +0100 Subject: [PATCH 2/3] Add a service provider that checks the current network status --- frontend/lib/api/api_status_provider.dart | 95 +++++++++++++++++++++++ frontend/lib/api/api_utils.dart | 4 +- frontend/lib/ui/main_scaffold.dart | 22 +++++- frontend/lib/utils/default_scaffold.dart | 32 -------- frontend/lib/utils/networking.dart | 21 +++-- frontend/pubspec.lock | 32 ++++---- 6 files changed, 150 insertions(+), 56 deletions(-) create mode 100644 frontend/lib/api/api_status_provider.dart delete mode 100644 frontend/lib/utils/default_scaffold.dart diff --git a/frontend/lib/api/api_status_provider.dart b/frontend/lib/api/api_status_provider.dart new file mode 100644 index 00000000..efa72487 --- /dev/null +++ b/frontend/lib/api/api_status_provider.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:zest/api/api_utils.dart'; +import 'package:zest/settings/settings_provider.dart'; + +part 'api_status_provider.g.dart'; // Generated file + +@riverpod +class ApiStatus extends _$ApiStatus { + Timer? _timer; + + @override + Future build() async { + startChecking(const Duration(seconds: 5)); + return await _checkBackendStatus(); + } + + Future _checkBackendStatus() async { + final SettingsState settings = ref.read(settingsProvider); + + try { + final response = + await http.get(getAPIUrl(settings, "/info", withPostSlash: false)); + return response.statusCode == 200; // Online if status is 200 + } catch (_) { + return false; // Offline in case of errors + } + } + + // Start periodic checks + void startChecking(Duration interval) { + _timer?.cancel(); // Cancel existing timer + _timer = Timer.periodic(interval, (_) async { + state = await AsyncValue.guard(() => _checkBackendStatus()); + }); + // Immediately update state + _updateStatus(); + } + + // Update the status once without waiting for the periodic interval + Future _updateStatus() async { + state = await AsyncValue.guard(() => _checkBackendStatus()); + } +} + +// @riverpod +// class ApiStatus extends AsyncNotifier { +// Timer? _timer; + +// @override +// Future build() async { +// // Initial state when the provider is first created +// return _checkBackendStatus(); +// } + +// // Function to check backend status +// Future _checkBackendStatus() async { +// try { +// final response = +// await http.get(Uri.parse('https://dbadrian/api/v1/info')); +// return response.statusCode == 200; // Online if status is 200 +// } catch (_) { +// return false; // Offline in case of errors +// } +// } + +// // Start periodic checks +// void startChecking(Duration interval) { +// _timer?.cancel(); // Cancel existing timer +// _timer = Timer.periodic(interval, (_) async { +// state = await AsyncValue.guard(() => _checkBackendStatus()); +// }); +// // Immediately update state +// _updateStatus(); +// } + +// // Update the status once without waiting for the periodic interval +// Future _updateStatus() async { +// state = await AsyncValue.guard(() => _checkBackendStatus()); +// } + +// // Stop periodic checks +// void stopChecking() { +// _timer?.cancel(); +// _timer = null; +// } + +// // @override +// // void dispose() { +// // stopChecking(); +// // super.dispose(); +// // } +// } diff --git a/frontend/lib/api/api_utils.dart b/frontend/lib/api/api_utils.dart index 7e2b8304..9598734a 100644 --- a/frontend/lib/api/api_utils.dart +++ b/frontend/lib/api/api_utils.dart @@ -1,9 +1,9 @@ import 'package:zest/settings/settings_provider.dart'; Uri getAPIUrl(SettingsState settings, String path, - {Map? queryParameters}) { + {Map? queryParameters, withPostSlash = true}) { final midSlash = path.startsWith('/') ? '' : '/'; - final postSlash = path.endsWith('/') ? '' : '/'; + final postSlash = path.endsWith('/') || !withPostSlash ? '' : '/'; return Uri.parse('${settings.apiUrl}$midSlash$path$postSlash') .replace(queryParameters: queryParameters); } diff --git a/frontend/lib/ui/main_scaffold.dart b/frontend/lib/ui/main_scaffold.dart index efbaabcf..e0b2b5e5 100644 --- a/frontend/lib/ui/main_scaffold.dart +++ b/frontend/lib/ui/main_scaffold.dart @@ -8,6 +8,7 @@ import 'package:downloadsfolder/downloadsfolder.dart'; import 'package:path/path.dart' as p; import 'package:zest/api/api_service.dart'; +import 'package:zest/api/api_status_provider.dart'; import 'package:zest/main.dart'; import 'package:zest/recipes/controller/search_controller.dart'; import 'package:zest/recipes/screens/recipe_draft_search.dart'; @@ -39,12 +40,31 @@ class MainScaffold extends ConsumerWidget { .select((value) => value.isAuthenticated)); final user = ref.watch(authenticationServiceProvider.notifier .select((value) => value.whoIsUser)); + + final backendStatus = ref.watch(apiStatusProvider); + return Scaffold( key: const Key('mainScaffold'), body: child, appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.primary, - // title: const Text("Zest"), + title: backendStatus.value ?? false + ? const Text("Online") + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off_rounded, + //set warning colors + color: Theme.of(context).colorScheme.onInverseSurface, + ), + const SizedBox(width: 5), + Text("Offline", + style: TextStyle( + color: Theme.of(context).colorScheme.onInverseSurface, + fontWeight: FontWeight.w600)), + ], + ), centerTitle: true, actions: [ if (isAuthenticated) diff --git a/frontend/lib/utils/default_scaffold.dart b/frontend/lib/utils/default_scaffold.dart deleted file mode 100644 index 995844c2..00000000 --- a/frontend/lib/utils/default_scaffold.dart +++ /dev/null @@ -1,32 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:zest/app/modules/recipe_search/views/recipe_search_view.dart'; -// import 'package:zest/app/modules/root/views/drawer.dart'; -// import 'package:zest/services/auth/auth_service.dart'; - -// Scaffold buildDefaultScaffold({ -// required BuildContext context, -// required Widget body, -// Widget? title, -// List? actions, -// Widget? floatingActionButton, -// }) { -// return Scaffold( -// drawer: const DrawerWidget(), -// appBar: AppBar( -// title: title ?? const Text('Zest'), -// centerTitle: true, -// actions: actions ?? -// [ -// if (AuthenticationService.to.isAuthenticated) -// IconButton( -// icon: const Icon(Icons.search), -// onPressed: () async { -// await showSearch(context: context, delegate: RecipeSearch()); -// }, -// ) -// ], -// ), -// body: body, -// floatingActionButton: floatingActionButton, -// ); -// } diff --git a/frontend/lib/utils/networking.dart b/frontend/lib/utils/networking.dart index c1e9fc38..412edf0d 100644 --- a/frontend/lib/utils/networking.dart +++ b/frontend/lib/utils/networking.dart @@ -116,11 +116,22 @@ class ResourceNotFoundInterceptor extends InterceptorContract { @override Future interceptResponse( {required BaseResponse response}) async { - if (response.statusCode == 404) { - if (response is Response) { - final ret = jsonDecodeResponseData(response); - throw ResourceNotFoundException(message: ret.toString()); - } + // if (response.statusCode == 404) { + // if (response is Response) { + // final ret = jsonDecodeResponseData(response); + // throw ResourceNotFoundException(message: ret.toString()); + // } + // } + + switch (response.statusCode) { + case 404: + throw ResourceNotFoundException(); + case 401: + throw NotAuthorizedException(); + case 400: + throw BadRequestException(); + case 500: + throw Exception("Server error"); } return response; } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 8134f551..0ac33812 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -372,10 +372,10 @@ packages: dependency: "direct main" description: name: flutter_form_builder - sha256: "39aee5a2548df0b3979a83eea38468116a888341fbca8a92c4be18a486a7bb57" + sha256: "375da52998c72f80dec9187bd93afa7ab202b89d5d066699368ff96d39fd4876" url: "https://pub.dev" source: hosted - version: "9.6.0" + version: "9.7.0" flutter_hooks: dependency: "direct main" description: @@ -468,26 +468,26 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + sha256: "1913841ac4c7bf57cd2e05b717e1fbff7841b542962feff827b16525a781b3e4" url: "https://pub.dev" source: hosted - version: "9.2.2" + version: "9.2.3" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" flutter_secure_storage_macos: dependency: transitive description: name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -651,10 +651,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" image: dependency: transitive description: @@ -1080,10 +1080,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "3c7e73920c694a436afaf65ab60ce3453d91f84208d761fbd83fc21182134d93" + sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_android: dependency: transitive description: @@ -1261,10 +1261,10 @@ packages: dependency: transitive description: name: sqflite_darwin - sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.1+1" sqflite_platform_interface: dependency: transitive description: @@ -1597,10 +1597,10 @@ packages: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.10.0" window_manager: dependency: transitive description: From b8f224c18c5c4c0c8674e5cbfa3341e1ec441536 Mon Sep 17 00:00:00 2001 From: "David B. Adrian" Date: Fri, 3 Jan 2025 17:58:39 +0100 Subject: [PATCH 3/3] Utilize network status to gray out create button --- frontend/lib/api/api_status_provider.dart | 4 ++++ frontend/lib/recipes/controller/edit_controller.dart | 8 +++++++- frontend/lib/ui/main_scaffold.dart | 10 ++++++---- frontend/lib/utils/networking.dart | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/lib/api/api_status_provider.dart b/frontend/lib/api/api_status_provider.dart index efa72487..546fa67d 100644 --- a/frontend/lib/api/api_status_provider.dart +++ b/frontend/lib/api/api_status_provider.dart @@ -43,6 +43,10 @@ class ApiStatus extends _$ApiStatus { Future _updateStatus() async { state = await AsyncValue.guard(() => _checkBackendStatus()); } + + Future updateStatus(bool isOnline) async { + state = AsyncValue.data(isOnline); + } } // @riverpod diff --git a/frontend/lib/recipes/controller/edit_controller.dart b/frontend/lib/recipes/controller/edit_controller.dart index db49ae63..64a8a9d4 100644 --- a/frontend/lib/recipes/controller/edit_controller.dart +++ b/frontend/lib/recipes/controller/edit_controller.dart @@ -8,8 +8,10 @@ import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:zest/api/api_service.dart'; +import 'package:zest/api/api_status_provider.dart'; import 'package:zest/main.dart'; import 'package:zest/recipes/controller/draft_controller.dart'; +import 'package:zest/recipes/screens/recipe_search.dart'; import 'package:zest/settings/settings_provider.dart'; import 'package:zest/utils/utils.dart'; @@ -217,7 +219,11 @@ class RecipeEditController extends _$RecipeEditController { // TODO: onconfirm ); } else if (categories.error is ServerNotReachableException) { - openServerNotAvailableDialog(); + openServerNotAvailableDialog(onPressed: () { + ref.read(apiStatusProvider.notifier).updateStatus(false); + shellNavigatorKey.currentState!.overlay!.context + .goNamed(RecipeSearchPage.routeName); + }); } } // express the list as a map diff --git a/frontend/lib/ui/main_scaffold.dart b/frontend/lib/ui/main_scaffold.dart index e0b2b5e5..9866df01 100644 --- a/frontend/lib/ui/main_scaffold.dart +++ b/frontend/lib/ui/main_scaffold.dart @@ -49,7 +49,7 @@ class MainScaffold extends ConsumerWidget { appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.primary, title: backendStatus.value ?? false - ? const Text("Online") + ? Container() : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -71,9 +71,11 @@ class MainScaffold extends ConsumerWidget { IconButton( icon: const Icon( key: Key("appbar_addrecipe_icon"), Icons.add_card_rounded), - onPressed: () { - context.goNamed(RecipeEditPage.routeNameCreate); - }, + onPressed: (backendStatus.value ?? false) + ? () { + context.goNamed(RecipeEditPage.routeNameCreate); + } + : null, ), if (isAuthenticated) IconButton( diff --git a/frontend/lib/utils/networking.dart b/frontend/lib/utils/networking.dart index 412edf0d..dc880b23 100644 --- a/frontend/lib/utils/networking.dart +++ b/frontend/lib/utils/networking.dart @@ -131,7 +131,7 @@ class ResourceNotFoundInterceptor extends InterceptorContract { case 400: throw BadRequestException(); case 500: - throw Exception("Server error"); + throw ServerNotReachableException(); // "Internal Server Error [500]" } return response; }