mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02: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:
@@ -1,5 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
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
|
||||
@@ -13,7 +15,13 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000, // 1 second, with exponential backoff
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<DesktopConfigSync />
|
||||
{children}
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
|
||||
export type BackendStatus = 'starting' | 'healthy' | 'unhealthy' | 'stopped';
|
||||
|
||||
interface BackendHealthState {
|
||||
status: BackendStatus;
|
||||
message?: string;
|
||||
lastChecked?: number;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
|
||||
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const [health, setHealth] = useState<BackendHealthState>({
|
||||
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;
|
||||
}
|
||||
}, []);
|
||||
export function useBackendHealth() {
|
||||
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
return backendHealthMonitor.subscribe(setHealth);
|
||||
}, []);
|
||||
|
||||
const initialize = async () => {
|
||||
setHealth((current) => ({
|
||||
...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]);
|
||||
const checkHealth = useCallback(async () => {
|
||||
return backendHealthMonitor.checkNow();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...health,
|
||||
isHealthy: health.status === 'healthy',
|
||||
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 { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
|
||||
interface EndpointConfig {
|
||||
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.
|
||||
*/
|
||||
@@ -14,38 +34,88 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState<boolean | null>(() => (endpoint ? true : null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 (!isMountedRef.current) return;
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>('/api/v1/config/endpoint-enabled', {
|
||||
params: { endpoint },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEnabled(response.data);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
||||
setError(message);
|
||||
setEnabled(null);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
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 {
|
||||
setLoading(false);
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [endpoint, clearRetryTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEndpointStatus();
|
||||
}, [endpoint]);
|
||||
if (!endpoint) {
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoint, fetchEndpointStatus]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
@@ -61,45 +131,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
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 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 (!isMountedRef.current) return;
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
||||
params: { endpoints: endpointsParam },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEndpointStatus(response.data);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
||||
setError(message);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
if (!isMountedRef.current) return;
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
acc[endpointName] = false;
|
||||
acc[endpointName] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(fallbackStatus);
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchAllEndpointStatuses();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [endpoints, clearRetryTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllEndpointStatuses();
|
||||
}, [endpoints.join(',')]);
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoints, fetchAllEndpointStatuses]);
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||
|
||||
export class TauriBackendService {
|
||||
private static instance: TauriBackendService;
|
||||
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 {
|
||||
if (!TauriBackendService.instance) {
|
||||
@@ -15,32 +21,84 @@ export class TauriBackendService {
|
||||
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> {
|
||||
if (this.backendStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke('start_backend', { backendUrl });
|
||||
console.log('Backend started:', result);
|
||||
this.backendStarted = true;
|
||||
|
||||
// Wait for backend to be healthy
|
||||
await this.waitForHealthy();
|
||||
} catch (error) {
|
||||
console.error('Failed to start backend:', error);
|
||||
throw error;
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
this.setStatus('starting');
|
||||
|
||||
this.startPromise = invoke('start_backend', { backendUrl })
|
||||
.then((result) => {
|
||||
console.log('Backend started:', result);
|
||||
this.backendStarted = true;
|
||||
this.setStatus('starting');
|
||||
this.beginHealthMonitoring();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setStatus('unhealthy');
|
||||
console.error('Failed to start backend:', 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> {
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await invoke('check_backend_health');
|
||||
const isHealthy = await invoke<boolean>('check_backend_health');
|
||||
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||
return isHealthy;
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +112,7 @@ export class TauriBackendService {
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
throw new Error('Backend failed to become healthy after 60 seconds');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user