mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Improve loading speed of desktop app (#4865)
# Description of Changes Improve loading speed of desktop app by loading a default config until the backend has spawned.
This commit is contained in:
parent
4d349c047b
commit
044bf3c2aa
@ -66,6 +66,7 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri-dev": "tauri dev --no-watch",
|
"tauri-dev": "tauri dev --no-watch",
|
||||||
"tauri-build": "tauri build",
|
"tauri-build": "tauri build",
|
||||||
|
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
|
||||||
"typecheck": "npm run typecheck:proprietary",
|
"typecheck": "npm run typecheck:proprietary",
|
||||||
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
|
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
|
||||||
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
|
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
|
||||||
|
|||||||
@ -5137,6 +5137,8 @@
|
|||||||
"backendHealth": {
|
"backendHealth": {
|
||||||
"checking": "Checking backend status...",
|
"checking": "Checking backend status...",
|
||||||
"online": "Backend Online",
|
"online": "Backend Online",
|
||||||
"offline": "Backend Offline"
|
"offline": "Backend Offline",
|
||||||
|
"starting": "Backend starting up...",
|
||||||
|
"wait": "Please wait for the backend to finish launching and try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
|||||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||||
import { AppConfigProvider, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
|
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
|
||||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
@ -31,22 +31,29 @@ function AppInitializer() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid requirement to have props which are required in app providers anyway
|
||||||
|
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
|
||||||
|
|
||||||
export interface AppProvidersProps {
|
export interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
appConfigRetryOptions?: AppConfigRetryOptions;
|
appConfigRetryOptions?: AppConfigRetryOptions;
|
||||||
|
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core application providers
|
* Core application providers
|
||||||
* Contains all providers needed for the core
|
* Contains all providers needed for the core
|
||||||
*/
|
*/
|
||||||
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
|
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<OnboardingProvider>
|
<OnboardingProvider>
|
||||||
<AppConfigProvider retryOptions={appConfigRetryOptions}>
|
<AppConfigProvider
|
||||||
|
retryOptions={appConfigRetryOptions}
|
||||||
|
{...appConfigProviderProps}
|
||||||
|
>
|
||||||
<ScarfTrackingInitializer />
|
<ScarfTrackingInitializer />
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
<AppInitializer />
|
<AppInitializer />
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Button } from '@mantine/core';
|
import { Button } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
|
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||||
|
|
||||||
export interface OperationButtonProps {
|
export interface OperationButtonProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@ -31,8 +33,14 @@ const OperationButton = ({
|
|||||||
'data-tour': dataTour
|
'data-tour': dataTour
|
||||||
}: OperationButtonProps) => {
|
}: OperationButtonProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isHealthy, message: backendMessage } = useBackendHealth();
|
||||||
|
const blockedByBackend = !isHealthy;
|
||||||
|
const combinedDisabled = disabled || blockedByBackend;
|
||||||
|
const tooltipLabel = blockedByBackend
|
||||||
|
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -41,7 +49,7 @@ const OperationButton = ({
|
|||||||
ml='md'
|
ml='md'
|
||||||
mt={mt}
|
mt={mt}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={disabled}
|
disabled={combinedDisabled}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
color={color}
|
color={color}
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
@ -54,6 +62,16 @@ const OperationButton = ({
|
|||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tooltipLabel) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltipLabel} position="top" arrow>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OperationButton;
|
export default OperationButton;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,6 +41,8 @@ export interface AppConfig {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
||||||
|
|
||||||
interface AppConfigContextValue {
|
interface AppConfigContextValue {
|
||||||
config: AppConfig | null;
|
config: AppConfig | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -59,26 +61,42 @@ const AppConfigContext = createContext<AppConfigContextValue | undefined>({
|
|||||||
* Provider component that fetches and provides app configuration
|
* Provider component that fetches and provides app configuration
|
||||||
* Should be placed at the top level of the app, before any components that need config
|
* Should be placed at the top level of the app, before any components that need config
|
||||||
*/
|
*/
|
||||||
export const AppConfigProvider: React.FC<{
|
export interface AppConfigProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
retryOptions?: AppConfigRetryOptions;
|
retryOptions?: AppConfigRetryOptions;
|
||||||
}> = ({ children, retryOptions }) => {
|
initialConfig?: AppConfig | null;
|
||||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
bootstrapMode?: AppConfigBootstrapMode;
|
||||||
const [loading, setLoading] = useState(true);
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||||
|
children,
|
||||||
|
retryOptions,
|
||||||
|
initialConfig = null,
|
||||||
|
bootstrapMode = 'blocking',
|
||||||
|
autoFetch = true,
|
||||||
|
}) => {
|
||||||
|
const isBlockingMode = bootstrapMode === 'blocking';
|
||||||
|
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [fetchCount, setFetchCount] = useState(0);
|
const [fetchCount, setFetchCount] = useState(0);
|
||||||
|
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
|
||||||
|
const [loading, setLoading] = useState(!hasResolvedConfig);
|
||||||
|
|
||||||
const maxRetries = retryOptions?.maxRetries ?? 0;
|
const maxRetries = retryOptions?.maxRetries ?? 0;
|
||||||
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
||||||
|
|
||||||
const fetchConfig = async (force = false) => {
|
const fetchConfig = useCallback(async (force = false) => {
|
||||||
// Prevent duplicate fetches unless forced
|
// Prevent duplicate fetches unless forced
|
||||||
if (!force && fetchCount > 0) {
|
if (!force && fetchCount > 0) {
|
||||||
console.debug('[AppConfig] Already fetched, skipping');
|
console.debug('[AppConfig] Already fetched, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
|
||||||
|
if (shouldBlockUI) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
@ -92,12 +110,13 @@ export const AppConfigProvider: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiClient automatically adds JWT header if available via interceptors
|
// apiClient automatically adds JWT header if available via interceptors
|
||||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
|
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
console.debug('[AppConfig] Config fetched successfully:', data);
|
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
setFetchCount(prev => prev + 1);
|
setFetchCount(prev => prev + 1);
|
||||||
|
setHasResolvedConfig(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return; // Success - exit function
|
return; // Success - exit function
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -108,6 +127,7 @@ export const AppConfigProvider: React.FC<{
|
|||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
||||||
setConfig({ enableLogin: true });
|
setConfig({ enableLogin: true });
|
||||||
|
setHasResolvedConfig(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -124,20 +144,23 @@ export const AppConfigProvider: React.FC<{
|
|||||||
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
|
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
|
||||||
// On error, assume login is enabled (safe default)
|
// Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled.
|
||||||
setConfig({ enableLogin: true });
|
setConfig((current) => current ?? { enableLogin: true });
|
||||||
|
setHasResolvedConfig(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always try to fetch config to check if login is disabled
|
// Always try to fetch config to check if login is disabled
|
||||||
// The endpoint should be public and return proper JSON
|
// The endpoint should be public and return proper JSON
|
||||||
|
if (autoFetch) {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}, []);
|
}
|
||||||
|
}, [autoFetch, fetchConfig]);
|
||||||
|
|
||||||
// Listen for JWT availability (triggered on login/signup)
|
// Listen for JWT availability (triggered on login/signup)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -149,7 +172,7 @@ export const AppConfigProvider: React.FC<{
|
|||||||
|
|
||||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||||
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||||
}, []);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
const value: AppConfigContextValue = {
|
const value: AppConfigContextValue = {
|
||||||
config,
|
config,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { ResponseHandler } from '@app/utils/toolResponseProcessor';
|
|||||||
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
||||||
import { ToolOperation } from '@app/types/file';
|
import { ToolOperation } from '@app/types/file';
|
||||||
import { ToolId } from '@app/types/toolId';
|
import { ToolId } from '@app/types/toolId';
|
||||||
|
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
@ -187,6 +188,12 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backendReady = await ensureBackendReady();
|
||||||
|
if (!backendReady) {
|
||||||
|
actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
actions.setLoading(true);
|
actions.setLoading(true);
|
||||||
actions.setError(null);
|
actions.setError(null);
|
||||||
|
|||||||
12
frontend/src/core/hooks/useBackendHealth.ts
Normal file
12
frontend/src/core/hooks/useBackendHealth.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||||
|
|
||||||
|
export function useBackendHealth(): BackendHealthState {
|
||||||
|
return {
|
||||||
|
status: 'healthy',
|
||||||
|
message: null,
|
||||||
|
isChecking: false,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
error: null,
|
||||||
|
isHealthy: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
7
frontend/src/core/services/backendReadinessGuard.ts
Normal file
7
frontend/src/core/services/backendReadinessGuard.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Default backend readiness guard (web builds do not need to wait for
|
||||||
|
* anything outside the browser, so we always report ready).
|
||||||
|
*/
|
||||||
|
export async function ensureBackendReady(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
10
frontend/src/core/types/backendHealth.ts
Normal file
10
frontend/src/core/types/backendHealth.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||||
|
|
||||||
|
export interface BackendHealthState {
|
||||||
|
status: BackendStatus;
|
||||||
|
message?: string | null;
|
||||||
|
lastChecked?: number;
|
||||||
|
isChecking: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isHealthy: boolean;
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
|
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||||
|
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop application providers
|
* Desktop application providers
|
||||||
@ -13,7 +15,13 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
maxRetries: 5,
|
maxRetries: 5,
|
||||||
initialDelay: 1000, // 1 second, with exponential backoff
|
initialDelay: 1000, // 1 second, with exponential backoff
|
||||||
}}
|
}}
|
||||||
|
appConfigProviderProps={{
|
||||||
|
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||||
|
bootstrapMode: 'non-blocking',
|
||||||
|
autoFetch: false,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<DesktopConfigSync />
|
||||||
{children}
|
{children}
|
||||||
</ProprietaryAppProviders>
|
</ProprietaryAppProviders>
|
||||||
);
|
);
|
||||||
|
|||||||
23
frontend/src/desktop/components/DesktopConfigSync.tsx
Normal file
23
frontend/src/desktop/components/DesktopConfigSync.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only bridge that refetches the app config once the bundled backend
|
||||||
|
* becomes healthy (and whenever it restarts). Keeps the UI responsive by using
|
||||||
|
* default config until the real config is available.
|
||||||
|
*/
|
||||||
|
export function DesktopConfigSync() {
|
||||||
|
const { status } = useBackendHealth();
|
||||||
|
const { refetch } = useAppConfig();
|
||||||
|
const previousStatus = useRef(status);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'healthy' && previousStatus.current !== 'healthy') {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
previousStatus.current = status;
|
||||||
|
}, [status, refetch]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
10
frontend/src/desktop/config/defaultAppConfig.ts
Normal file
10
frontend/src/desktop/config/defaultAppConfig.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { AppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration used while the bundled backend starts up.
|
||||||
|
*/
|
||||||
|
export const DESKTOP_DEFAULT_APP_CONFIG: AppConfig = {
|
||||||
|
enableLogin: false,
|
||||||
|
premiumEnabled: false,
|
||||||
|
runningProOrHigher: false,
|
||||||
|
};
|
||||||
20
frontend/src/desktop/constants/backendErrors.ts
Normal file
20
frontend/src/desktop/constants/backendErrors.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
|
export const BACKEND_NOT_READY_CODE = 'BACKEND_NOT_READY' as const;
|
||||||
|
|
||||||
|
export interface BackendNotReadyError extends Error {
|
||||||
|
code: typeof BACKEND_NOT_READY_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackendNotReadyError(): BackendNotReadyError {
|
||||||
|
return Object.assign(new Error(i18n.t('backendHealth.starting', 'Backend starting up...')), {
|
||||||
|
code: BACKEND_NOT_READY_CODE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackendNotReadyError(error: unknown): error is BackendNotReadyError {
|
||||||
|
return typeof error === 'object'
|
||||||
|
&& error !== null
|
||||||
|
&& 'code' in error
|
||||||
|
&& (error as { code?: unknown }).code === BACKEND_NOT_READY_CODE;
|
||||||
|
}
|
||||||
@ -1,91 +1,24 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
|
||||||
|
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||||
export type BackendStatus = 'starting' | 'healthy' | 'unhealthy' | 'stopped';
|
|
||||||
|
|
||||||
interface BackendHealthState {
|
|
||||||
status: BackendStatus;
|
|
||||||
message?: string;
|
|
||||||
lastChecked?: number;
|
|
||||||
isChecking: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to monitor backend health status with retries
|
* Hook to read the shared backend health monitor state.
|
||||||
|
* All consumers subscribe to a single poller managed by backendHealthMonitor.
|
||||||
*/
|
*/
|
||||||
export function useBackendHealth(pollingInterval = 5000) {
|
export function useBackendHealth() {
|
||||||
const [health, setHealth] = useState<BackendHealthState>({
|
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
|
||||||
status: tauriBackendService.isBackendRunning() ? 'healthy' : 'stopped',
|
|
||||||
isChecking: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
|
||||||
setHealth((current) => ({
|
|
||||||
...current,
|
|
||||||
status: current.status === 'healthy' ? 'healthy' : 'starting',
|
|
||||||
isChecking: true,
|
|
||||||
error: 'Backend starting up...',
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isHealthy = await tauriBackendService.checkBackendHealth();
|
|
||||||
|
|
||||||
setHealth({
|
|
||||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
message: isHealthy ? 'Backend is healthy' : 'Backend is unavailable',
|
|
||||||
isChecking: false,
|
|
||||||
error: isHealthy ? null : 'Backend offline',
|
|
||||||
});
|
|
||||||
|
|
||||||
return isHealthy;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[BackendHealth] Health check failed:', error);
|
|
||||||
setHealth({
|
|
||||||
status: 'unhealthy',
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
message: 'Backend is unavailable',
|
|
||||||
isChecking: false,
|
|
||||||
error: 'Backend offline',
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
return backendHealthMonitor.subscribe(setHealth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const initialize = async () => {
|
const checkHealth = useCallback(async () => {
|
||||||
setHealth((current) => ({
|
return backendHealthMonitor.checkNow();
|
||||||
...current,
|
}, []);
|
||||||
status: tauriBackendService.isBackendRunning() ? 'starting' : 'stopped',
|
|
||||||
isChecking: true,
|
|
||||||
error: 'Backend starting up...',
|
|
||||||
}));
|
|
||||||
|
|
||||||
await checkHealth();
|
|
||||||
if (!isMounted) return;
|
|
||||||
};
|
|
||||||
|
|
||||||
initialize();
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
void checkHealth();
|
|
||||||
}, pollingInterval);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [checkHealth, pollingInterval]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...health,
|
...health,
|
||||||
isHealthy: health.status === 'healthy',
|
|
||||||
checkHealth,
|
checkHealth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,30 @@
|
|||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||||
|
|
||||||
interface EndpointConfig {
|
interface EndpointConfig {
|
||||||
backendUrl: string;
|
backendUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RETRY_DELAY_MS = 2500;
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
const data = err.response?.data as { message?: string } | undefined;
|
||||||
|
if (typeof data?.message === 'string') {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
return err.message || 'Unknown error occurred';
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return 'Unknown error occurred';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop-specific endpoint checker that hits the backend directly via axios.
|
* Desktop-specific endpoint checker that hits the backend directly via axios.
|
||||||
*/
|
*/
|
||||||
@ -14,38 +34,88 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
refetch: () => Promise<void>;
|
refetch: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(true);
|
const [enabled, setEnabled] = useState<boolean | null>(() => (endpoint ? true : null));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearRetryTimeout = useCallback(() => {
|
||||||
|
if (retryTimeoutRef.current) {
|
||||||
|
clearTimeout(retryTimeoutRef.current);
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
clearRetryTimeout();
|
||||||
|
};
|
||||||
|
}, [clearRetryTimeout]);
|
||||||
|
|
||||||
|
const fetchEndpointStatus = useCallback(async () => {
|
||||||
|
clearRetryTimeout();
|
||||||
|
|
||||||
const fetchEndpointStatus = async () => {
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setEnabled(null);
|
setEnabled(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await apiClient.get<boolean>('/api/v1/config/endpoint-enabled', {
|
const response = await apiClient.get<boolean>('/api/v1/config/endpoint-enabled', {
|
||||||
params: { endpoint },
|
params: { endpoint },
|
||||||
|
suppressErrorToast: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setEnabled(response.data);
|
setEnabled(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
const isBackendStarting = isBackendNotReadyError(err);
|
||||||
setError(message);
|
const message = getErrorMessage(err);
|
||||||
setEnabled(null);
|
if (!isMountedRef.current) return;
|
||||||
|
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||||
|
setEnabled(true);
|
||||||
|
|
||||||
|
if (!retryTimeoutRef.current) {
|
||||||
|
retryTimeoutRef.current = setTimeout(() => {
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
fetchEndpointStatus();
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}, [endpoint, clearRetryTimeout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!endpoint) {
|
||||||
|
setEnabled(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tauriBackendService.isBackendHealthy()) {
|
||||||
fetchEndpointStatus();
|
fetchEndpointStatus();
|
||||||
}, [endpoint]);
|
}
|
||||||
|
|
||||||
|
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||||
|
if (status === 'healthy') {
|
||||||
|
fetchEndpointStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [endpoint, fetchEndpointStatus]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
@ -61,45 +131,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
refetch: () => Promise<void>;
|
refetch: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(true);
|
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(() => {
|
||||||
|
if (!endpoints || endpoints.length === 0) return {};
|
||||||
|
return endpoints.reduce((acc, endpointName) => {
|
||||||
|
acc[endpointName] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearRetryTimeout = useCallback(() => {
|
||||||
|
if (retryTimeoutRef.current) {
|
||||||
|
clearTimeout(retryTimeoutRef.current);
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
clearRetryTimeout();
|
||||||
|
};
|
||||||
|
}, [clearRetryTimeout]);
|
||||||
|
|
||||||
|
const fetchAllEndpointStatuses = useCallback(async () => {
|
||||||
|
clearRetryTimeout();
|
||||||
|
|
||||||
const fetchAllEndpointStatuses = async () => {
|
|
||||||
if (!endpoints || endpoints.length === 0) {
|
if (!endpoints || endpoints.length === 0) {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setEndpointStatus({});
|
setEndpointStatus({});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const endpointsParam = endpoints.join(',');
|
const endpointsParam = endpoints.join(',');
|
||||||
|
|
||||||
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
||||||
params: { endpoints: endpointsParam },
|
params: { endpoints: endpointsParam },
|
||||||
|
suppressErrorToast: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setEndpointStatus(response.data);
|
setEndpointStatus(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
const isBackendStarting = isBackendNotReadyError(err);
|
||||||
setError(message);
|
const message = getErrorMessage(err);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||||
|
|
||||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||||
acc[endpointName] = false;
|
acc[endpointName] = true;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, boolean>);
|
}, {} as Record<string, boolean>);
|
||||||
setEndpointStatus(fallbackStatus);
|
setEndpointStatus(fallbackStatus);
|
||||||
|
|
||||||
|
if (!retryTimeoutRef.current) {
|
||||||
|
retryTimeoutRef.current = setTimeout(() => {
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
fetchAllEndpointStatuses();
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}, [endpoints, clearRetryTimeout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!endpoints || endpoints.length === 0) {
|
||||||
|
setEndpointStatus({});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tauriBackendService.isBackendHealthy()) {
|
||||||
fetchAllEndpointStatuses();
|
fetchAllEndpointStatuses();
|
||||||
}, [endpoints.join(',')]);
|
}
|
||||||
|
|
||||||
|
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||||
|
if (status === 'healthy') {
|
||||||
|
fetchAllEndpointStatuses();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [endpoints, fetchAllEndpointStatuses]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpointStatus,
|
endpointStatus,
|
||||||
|
|||||||
44
frontend/src/desktop/services/apiClientSetup.ts
Normal file
44
frontend/src/desktop/services/apiClientSetup.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
||||||
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
|
let lastBackendToast = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-specific API interceptors
|
||||||
|
* - Reuses the core interceptors
|
||||||
|
* - Blocks API calls while the bundled backend is still starting and shows
|
||||||
|
* a friendly toast for user-initiated requests (non-GET)
|
||||||
|
*/
|
||||||
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
|
coreSetup(client);
|
||||||
|
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const skipCheck = config?.skipBackendReadyCheck === true;
|
||||||
|
if (skipCheck || tauriBackendService.isBackendHealthy()) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (config.method || 'get').toLowerCase();
|
||||||
|
if (method !== 'get') {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||||
|
lastBackendToast = now;
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(createBackendNotReadyError());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/desktop/services/backendHealthMonitor.ts
Normal file
125
frontend/src/desktop/services/backendHealthMonitor.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import i18n from '@app/i18n';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||||
|
|
||||||
|
type Listener = (state: BackendHealthState) => void;
|
||||||
|
|
||||||
|
class BackendHealthMonitor {
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
private state: BackendHealthState = {
|
||||||
|
status: tauriBackendService.getBackendStatus(),
|
||||||
|
isChecking: false,
|
||||||
|
error: null,
|
||||||
|
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(pollingInterval = 5000) {
|
||||||
|
this.intervalMs = pollingInterval;
|
||||||
|
|
||||||
|
// Reflect status updates from the backend service immediately
|
||||||
|
tauriBackendService.subscribeToStatus((status) => {
|
||||||
|
this.updateState({
|
||||||
|
status,
|
||||||
|
error: status === 'healthy' ? null : this.state.error,
|
||||||
|
message: status === 'healthy'
|
||||||
|
? i18n.t('backendHealth.online', 'Backend Online')
|
||||||
|
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
isChecking: status === 'healthy' ? false : this.state.isChecking,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateState(partial: Partial<BackendHealthState>) {
|
||||||
|
const nextStatus = partial.status ?? this.state.status;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
...partial,
|
||||||
|
status: nextStatus,
|
||||||
|
isHealthy: nextStatus === 'healthy',
|
||||||
|
};
|
||||||
|
this.listeners.forEach((listener) => listener(this.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensurePolling() {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.pollOnce();
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
void this.pollOnce();
|
||||||
|
}, this.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollOnce(): Promise<boolean> {
|
||||||
|
this.updateState({
|
||||||
|
isChecking: true,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
error: this.state.error ?? 'Backend offline',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthy = await tauriBackendService.checkBackendHealth();
|
||||||
|
if (healthy) {
|
||||||
|
this.updateState({
|
||||||
|
status: 'healthy',
|
||||||
|
isChecking: false,
|
||||||
|
message: i18n.t('backendHealth.online', 'Backend Online'),
|
||||||
|
error: null,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.updateState({
|
||||||
|
status: 'unhealthy',
|
||||||
|
isChecking: false,
|
||||||
|
message: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
error: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return healthy;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackendHealthMonitor] Health check failed:', error);
|
||||||
|
this.updateState({
|
||||||
|
status: 'unhealthy',
|
||||||
|
isChecking: false,
|
||||||
|
message: 'Backend is unavailable',
|
||||||
|
error: 'Backend offline',
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.state);
|
||||||
|
if (this.listeners.size === 1) {
|
||||||
|
this.ensurePolling();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
if (this.listeners.size === 0) {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): BackendHealthState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkNow(): Promise<boolean> {
|
||||||
|
return this.pollOnce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backendHealthMonitor = new BackendHealthMonitor();
|
||||||
35
frontend/src/desktop/services/backendReadinessGuard.ts
Normal file
35
frontend/src/desktop/services/backendReadinessGuard.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import i18n from '@app/i18n';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
|
||||||
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
|
let lastBackendToast = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-specific guard that ensures the embedded backend is healthy
|
||||||
|
* before tools attempt to call any API endpoints.
|
||||||
|
*/
|
||||||
|
export async function ensureBackendReady(): Promise<boolean> {
|
||||||
|
if (tauriBackendService.isBackendHealthy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a health check so we get the freshest status
|
||||||
|
await tauriBackendService.checkBackendHealth();
|
||||||
|
if (tauriBackendService.isBackendHealthy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||||
|
lastBackendToast = now;
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
body: i18n.t('backendHealth.checking', 'Checking backend status...'),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,8 +1,14 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||||
|
|
||||||
export class TauriBackendService {
|
export class TauriBackendService {
|
||||||
private static instance: TauriBackendService;
|
private static instance: TauriBackendService;
|
||||||
private backendStarted = false;
|
private backendStarted = false;
|
||||||
|
private backendStatus: BackendStatus = 'stopped';
|
||||||
|
private healthMonitor: Promise<void> | null = null;
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
private statusListeners = new Set<(status: BackendStatus) => void>();
|
||||||
|
|
||||||
static getInstance(): TauriBackendService {
|
static getInstance(): TauriBackendService {
|
||||||
if (!TauriBackendService.instance) {
|
if (!TauriBackendService.instance) {
|
||||||
@ -15,32 +21,84 @@ export class TauriBackendService {
|
|||||||
return this.backendStarted;
|
return this.backendStarted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBackendStatus(): BackendStatus {
|
||||||
|
return this.backendStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBackendHealthy(): boolean {
|
||||||
|
return this.backendStatus === 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
|
||||||
|
this.statusListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.statusListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: BackendStatus) {
|
||||||
|
if (this.backendStatus === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.backendStatus = status;
|
||||||
|
this.statusListeners.forEach(listener => listener(status));
|
||||||
|
}
|
||||||
|
|
||||||
async startBackend(backendUrl?: string): Promise<void> {
|
async startBackend(backendUrl?: string): Promise<void> {
|
||||||
if (this.backendStarted) {
|
if (this.backendStarted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.startPromise) {
|
||||||
const result = await invoke('start_backend', { backendUrl });
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus('starting');
|
||||||
|
|
||||||
|
this.startPromise = invoke('start_backend', { backendUrl })
|
||||||
|
.then((result) => {
|
||||||
console.log('Backend started:', result);
|
console.log('Backend started:', result);
|
||||||
this.backendStarted = true;
|
this.backendStarted = true;
|
||||||
|
this.setStatus('starting');
|
||||||
// Wait for backend to be healthy
|
this.beginHealthMonitoring();
|
||||||
await this.waitForHealthy();
|
})
|
||||||
} catch (error) {
|
.catch((error) => {
|
||||||
|
this.setStatus('unhealthy');
|
||||||
console.error('Failed to start backend:', error);
|
console.error('Failed to start backend:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.startPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private beginHealthMonitoring() {
|
||||||
|
if (this.healthMonitor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.healthMonitor = this.waitForHealthy()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Backend failed to become healthy:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.healthMonitor = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkBackendHealth(): Promise<boolean> {
|
async checkBackendHealth(): Promise<boolean> {
|
||||||
if (!this.backendStarted) {
|
if (!this.backendStarted) {
|
||||||
|
this.setStatus('stopped');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await invoke('check_backend_health');
|
const isHealthy = await invoke<boolean>('check_backend_health');
|
||||||
|
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||||
|
return isHealthy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Health check failed:', error);
|
console.error('Health check failed:', error);
|
||||||
|
this.setStatus('unhealthy');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,6 +112,7 @@ export class TauriBackendService {
|
|||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
this.setStatus('unhealthy');
|
||||||
throw new Error('Backend failed to become healthy after 60 seconds');
|
throw new Error('Backend failed to become healthy after 60 seconds');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/src/global.d.ts
vendored
12
frontend/src/global.d.ts
vendored
@ -12,4 +12,16 @@ declare module 'assets/material-symbols-icons.json' {
|
|||||||
export default value;
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'axios' {
|
||||||
|
export interface AxiosRequestConfig<_D = unknown> {
|
||||||
|
suppressErrorToast?: boolean;
|
||||||
|
skipBackendReadyCheck?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternalAxiosRequestConfig<_D = unknown> {
|
||||||
|
suppressErrorToast?: boolean;
|
||||||
|
skipBackendReadyCheck?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
|
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
|
||||||
import { AuthProvider } from "@app/auth/UseSession";
|
import { AuthProvider } from "@app/auth/UseSession";
|
||||||
|
|
||||||
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
|
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<CoreAppProviders appConfigRetryOptions={appConfigRetryOptions}>
|
<CoreAppProviders
|
||||||
|
appConfigRetryOptions={appConfigRetryOptions}
|
||||||
|
appConfigProviderProps={appConfigProviderProps}
|
||||||
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user