feat(server): Improve server health checks and authentication flow

This commit is contained in:
cogwheel0
2025-11-26 15:25:02 +05:30
parent 9b69290589
commit 44d1cc99b4
7 changed files with 166 additions and 35 deletions

View File

@@ -30,6 +30,7 @@ class ApiAuthInterceptor extends Interceptor {
// Endpoints that have optional authentication (work without but better with)
static const Set<String> _optionalAuthEndpoints = {
'/api/config',
'/api/models',
'/api/v1/configs/models',
};

View File

@@ -81,8 +81,14 @@ class RouterNotifier extends ChangeNotifier {
final activeServer = activeServerAsync.asData?.value;
final hasActiveServer = activeServer != null;
if (!hasActiveServer) {
// Allow auth-related routes while no server configured
if (_isAuthLocation(location)) return null;
// No server configured - redirect to server connection
// Exception: allow staying on server connection or authentication pages
// But always redirect away from connection issue page (user logged out)
if (location == Routes.serverConnection ||
location == Routes.authentication ||
location == Routes.login) {
return null;
}
return Routes.serverConnection;
}

View File

@@ -219,7 +219,7 @@ class ApiService {
return parsed;
}
// Health check
/// Basic health check - just verifies the server is reachable.
Future<bool> checkHealth() async {
try {
final response = await _dio.get('/health');
@@ -229,6 +229,35 @@ class ApiService {
}
}
/// Verifies this is actually an OpenWebUI server by checking the /api/config
/// endpoint for OpenWebUI-specific fields (version, status, features).
///
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
Future<bool> verifyIsOpenWebUIServer() async {
try {
final response = await _dio.get('/api/config');
if (response.statusCode != 200) {
return false;
}
final data = response.data;
if (data is! Map<String, dynamic>) {
return false;
}
// Check for OpenWebUI-specific fields
// The /api/config endpoint always returns these fields on OpenWebUI
final hasStatus = data['status'] == true;
final hasVersion =
data['version'] is String && (data['version'] as String).isNotEmpty;
final hasFeatures = data['features'] is Map;
return hasStatus && hasVersion && hasFeatures;
} catch (e) {
return false;
}
}
// Enhanced health check with model availability
Future<Map<String, dynamic>> checkServerStatus() async {
final result = <String, dynamic>{

View File

@@ -32,10 +32,18 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
void Function(FlutterErrorDetails details)? _previousOnError;
bool _shouldIgnoreError(Object error) {
// Ignore RenderFlex overflow errors (layout issues)
final errorString = error.toString();
return errorString.contains('RenderFlex') ||
errorString.contains('overflow') && errorString.contains('pixels');
// Ignore RenderFlex overflow errors (layout issues)
if (errorString.contains('RenderFlex') ||
errorString.contains('overflow') && errorString.contains('pixels')) {
return true;
}
// Ignore "Build scheduled during frame" errors - these are harmless
// framework warnings from animations during layout
if (errorString.contains('Build scheduled during frame')) {
return true;
}
return false;
}
void _scheduleHandleError(Object error, StackTrace? stack) {
@@ -59,6 +67,10 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
// Set up Flutter error handling for this widget
_previousOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
// Check if this is a harmless error we should completely ignore
if (_shouldIgnoreError(details.exception)) {
return; // Don't forward or handle
}
// Forward to any previously registered handler to avoid interfering
_previousOnError?.call(details);
// Defer handling to avoid setState during build

View File

@@ -37,6 +37,7 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
bool _useApiKey = false;
String? _loginError;
bool _isSigningIn = false;
bool _serverConfigSaved = false;
@override
void initState() {
@@ -72,13 +73,20 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
});
try {
// Save server config on first sign-in attempt if it's a new config
// This persists the server so user can retry with different credentials
if (widget.serverConfig != null && !_serverConfigSaved) {
await _saveServerConfig(widget.serverConfig!);
_serverConfigSaved = true;
}
final actions = ref.read(authActionsProvider);
bool success;
if (_useApiKey) {
success = await actions.loginWithApiKey(
_apiKeyController.text.trim(),
rememberCredentials: true, // Consistent with credentials method
rememberCredentials: true,
);
} else {
success = await actions.login(
@@ -95,6 +103,9 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
// Success - navigation will be handled by auth state change
} catch (e) {
// Don't clear server config on auth failure - user should be able to retry
// The server config is valid (passed OpenWebUI verification), only the
// credentials were wrong or there was a network issue
setState(() {
_loginError = _formatLoginError(e.toString());
});
@@ -107,6 +118,14 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
}
}
Future<void> _saveServerConfig(ServerConfig config) async {
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveServerConfigs([config]);
await storage.setActiveServerId(config.id);
ref.invalidate(serverConfigsProvider);
ref.invalidate(activeServerProvider);
}
String _formatLoginError(String error) {
if (error.contains('401') || error.contains('Unauthorized')) {
return AppLocalizations.of(context)!.invalidCredentials;
@@ -164,6 +183,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
// Main content
Expanded(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Form(

View File

@@ -215,19 +215,31 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
}
Future<void> _retryConnection() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_isRetrying = true;
_statusMessage = null;
});
try {
// Clear the error state and attempt to re-establish connection
final authManager = ref.read(authStateManagerProvider.notifier);
final authState = ref.read(authStateManagerProvider);
final hasValidToken = authState.maybeWhen(
data: (state) => state.hasValidToken,
orElse: () => false,
);
// Reset retry counter for manual retry attempts
authManager.resetRetryCounter();
await authManager.silentLogin();
if (hasValidToken) {
// User has a valid token - just refresh to verify connection
await authManager.refresh();
} else {
// No valid token - attempt silent login with saved credentials
await authManager.silentLogin();
}
// If successful, router will automatically navigate to chat
if (!mounted) return;
@@ -237,7 +249,7 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
} catch (_) {
if (!mounted) return;
setState(() {
_statusMessage = 'Connection failed. Please try again.';
_statusMessage = l10n.couldNotConnectGeneric;
});
} finally {
if (mounted) {

View File

@@ -14,6 +14,7 @@ import '../../../core/services/api_service.dart';
import '../../../core/services/worker_manager.dart';
import '../../../core/services/input_validation_service.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/widgets/error_boundary.dart';
import '../../../shared/services/brand_service.dart';
import '../../../shared/theme/theme_extensions.dart';
@@ -63,7 +64,22 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
Future<void> _connectToServer() async {
if (!_formKey.currentState!.validate()) return;
DebugLogger.log('Connect button pressed', scope: 'auth/connection');
final urlValue = _urlController.text.trim();
DebugLogger.log('URL value: "$urlValue"', scope: 'auth/connection');
// Check what validation would return
final validationResult = InputValidationService.validateUrl(urlValue);
DebugLogger.log(
'URL validation result: ${validationResult ?? "valid"}',
scope: 'auth/connection',
);
if (!_formKey.currentState!.validate()) {
DebugLogger.log('Form validation failed', scope: 'auth/connection');
return;
}
setState(() {
_isConnecting = true;
@@ -87,21 +103,56 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
serverConfig: tempConfig,
workerManager: workerManager,
);
final isHealthy = await api.checkHealth();
if (!isHealthy) {
// First check basic connectivity
DebugLogger.log('Checking server health...', scope: 'auth/connection');
final isReachable = await api.checkHealth();
DebugLogger.log(
'Health check result: $isReachable',
scope: 'auth/connection',
);
if (!isReachable) {
throw Exception(
'Could not reach the server. Please check the address.',
);
}
// Then verify it's actually an OpenWebUI server
DebugLogger.log(
'Verifying OpenWebUI server...',
scope: 'auth/connection',
);
final isOpenWebUI = await api.verifyIsOpenWebUIServer();
DebugLogger.log(
'OpenWebUI verification result: $isOpenWebUI',
scope: 'auth/connection',
);
if (!isOpenWebUI) {
throw Exception('This does not appear to be an Open-WebUI server.');
}
await _saveServerConfig(tempConfig);
DebugLogger.log(
'Server validation passed, navigating to auth page',
scope: 'auth/connection',
);
// Navigate to authentication page
// Don't save server config yet - wait until authentication succeeds
// The config is passed to the authentication page
if (mounted) {
context.pushNamed(RouteNames.authentication, extra: tempConfig);
}
} catch (e) {
setState(() {
_connectionError = _formatConnectionError(e.toString());
});
} catch (e, stack) {
DebugLogger.error(
'server-connection-error',
scope: 'auth/connection',
error: e,
stackTrace: stack,
);
if (mounted) {
setState(() {
_connectionError = _formatConnectionError(e.toString());
});
}
} finally {
if (mounted) {
setState(() {
@@ -111,14 +162,6 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
}
Future<void> _saveServerConfig(ServerConfig config) async {
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveServerConfigs([config]);
await storage.setActiveServerId(config.id);
ref.invalidate(serverConfigsProvider);
ref.invalidate(activeServerProvider);
}
String _validateAndFormatUrl(String input) {
if (input.isEmpty) {
throw Exception(AppLocalizations.of(context)!.serverUrlEmpty);
@@ -244,6 +287,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
// Main content
Expanded(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Form(
@@ -528,12 +573,16 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
),
),
),
AnimatedSize(
// Use AnimatedCrossFade instead of AnimatedSize to avoid
// "Build scheduled during frame" errors
AnimatedCrossFade(
duration: AnimationDuration.microInteraction,
curve: Curves.easeInOutCubic,
child: _showAdvancedSettings
? _buildAdvancedSettingsContent()
: const SizedBox.shrink(),
sizeCurve: Curves.easeInOutCubic,
crossFadeState: _showAdvancedSettings
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
secondChild: _buildAdvancedSettingsContent(),
),
],
);
@@ -845,8 +894,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
String? _validateHeaderKey(String key) {
// RFC 7230 compliant header name validation
if (key.isEmpty) return AppLocalizations.of(context)!.headerNameEmpty;
// Allow empty - header fields are optional
if (key.isEmpty) return null;
if (key.length > 64) return AppLocalizations.of(context)!.headerNameTooLong;
// Check for valid characters (RFC 7230: token characters)
@@ -879,7 +928,8 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
}
String? _validateHeaderValue(String value) {
if (value.isEmpty) return AppLocalizations.of(context)!.headerValueEmpty;
// Allow empty - header fields are optional
if (value.isEmpty) return null;
if (value.length > 1024) {
return AppLocalizations.of(context)!.headerValueTooLong;
}