mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-06 03:19:39 +02:00
Desktop: Fallback to local backend if self-hosted server is offline (#5880)
* Adds a fallback mechanism so the desktop app routes tool operations to the local bundled backend when the user's self-hosted Stirling-PDF server goes offline, and disables tools in the UI that aren't supported locally. * `selfHostedServerMonitor.ts` independently polls the self-hosted server every 15s and exposes which tool endpoints are unavailable when it goes offline * `operationRouter.ts` intercepts operations destined for the self-hosted server and reroutes them to the local bundled backend when the monitor reports it offline * `useSelfHostedToolAvailability.ts` feeds the offline tool set into useToolManagement, disabling affected tools in the UI with a selfHostedOffline reason and banner warning - `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar shown at the top of the UI when in self-hosted mode and the server goes offline. It shows:
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl, Tooltip, Box } from "@mantine/core";
|
||||
import SliderWithInput from '@app/components/shared/sliderWithInput/SliderWithInput';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useGroupEnabled } from "@app/hooks/useGroupEnabled";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
@@ -15,20 +14,7 @@ interface CompressSettingsProps {
|
||||
|
||||
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkImageMagick = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
|
||||
setImageMagickAvailable(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check ImageMagick availability:', error);
|
||||
setImageMagickAvailable(true); // Optimistic fallback
|
||||
}
|
||||
};
|
||||
checkImageMagick();
|
||||
}, []);
|
||||
const { enabled: imageMagickAvailable, unavailableReason: imageMagickReason } = useGroupEnabled('ImageMagick');
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -122,17 +108,27 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Checkbox
|
||||
checked={parameters.lineArt}
|
||||
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
|
||||
description={
|
||||
imageMagickAvailable === false
|
||||
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
|
||||
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
label={imageMagickReason ?? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")}
|
||||
disabled={imageMagickAvailable !== false}
|
||||
multiline
|
||||
maw={280}
|
||||
>
|
||||
<Box style={{ cursor: imageMagickAvailable === false ? 'not-allowed' : undefined }}>
|
||||
<Checkbox
|
||||
checked={parameters.lineArt}
|
||||
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
|
||||
description={
|
||||
imageMagickAvailable !== false
|
||||
? t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
|
||||
: undefined
|
||||
}
|
||||
style={{ pointerEvents: imageMagickAvailable === false ? 'none' : undefined }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{parameters.lineArt && (
|
||||
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
|
||||
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | null;
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | 'selfHostedOffline' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
@@ -43,6 +43,9 @@ export const getToolDisabledReason = (
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'selfHostedOffline') {
|
||||
return 'selfHostedOffline';
|
||||
}
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
return 'missingDependency';
|
||||
}
|
||||
@@ -64,6 +67,12 @@ export const getDisabledLabel = (
|
||||
fallback: 'Premium feature:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'selfHostedOffline') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.selfHostedOffline',
|
||||
fallback: 'Requires your Stirling-PDF server (currently offline):'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
|
||||
@@ -36,8 +36,8 @@ const OperationButton = ({
|
||||
'data-tour': dataTour
|
||||
}: OperationButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isHealthy, message: backendMessage } = useBackendHealth();
|
||||
const blockedByBackend = !isHealthy;
|
||||
const { isOnline, message: backendMessage } = useBackendHealth();
|
||||
const blockedByBackend = !isOnline;
|
||||
const combinedDisabled = disabled || blockedByBackend;
|
||||
const tooltipLabel = blockedByBackend
|
||||
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
|
||||
|
||||
@@ -5,6 +5,6 @@ export function useBackendHealth(): BackendHealthState {
|
||||
status: 'healthy',
|
||||
message: null,
|
||||
error: null,
|
||||
isHealthy: true,
|
||||
isOnline: true,
|
||||
};
|
||||
}
|
||||
|
||||
28
frontend/src/core/hooks/useGroupEnabled.ts
Normal file
28
frontend/src/core/hooks/useGroupEnabled.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { GroupEnabledResult } from '@app/types/groupEnabled';
|
||||
|
||||
export type { GroupEnabledResult };
|
||||
|
||||
/**
|
||||
* Checks whether a named feature group is enabled on the backend.
|
||||
* Returns { enabled: null } while loading, then true/false with an optional reason.
|
||||
*/
|
||||
export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
const [result, setResult] = useState<GroupEnabledResult>({ enabled: null, unavailableReason: null });
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => { isMountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
|
||||
.then(res => { if (isMountedRef.current) setResult({ enabled: res.data, unavailableReason: null }); })
|
||||
.catch(() => { if (isMountedRef.current) setResult({ enabled: false, unavailableReason: null }); });
|
||||
}, [group]);
|
||||
|
||||
return result;
|
||||
}
|
||||
8
frontend/src/core/hooks/useSaaSMode.ts
Normal file
8
frontend/src/core/hooks/useSaaSMode.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Stub implementation for web builds.
|
||||
* In desktop builds this is shadowed by desktop/hooks/useSaaSMode.ts which
|
||||
* returns whether the app is currently in SaaS connection mode (vs self-hosted).
|
||||
*/
|
||||
export function useSaaSMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
11
frontend/src/core/hooks/useSelfHostedToolAvailability.ts
Normal file
11
frontend/src/core/hooks/useSelfHostedToolAvailability.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Stub implementation for web / SaaS builds.
|
||||
* In self-hosted desktop mode this is shadowed by the desktop override which
|
||||
* returns the set of tool IDs that are unavailable when the self-hosted server
|
||||
* is offline (i.e. tools whose endpoints the local bundled backend does not support).
|
||||
*/
|
||||
export function useSelfHostedToolAvailability(
|
||||
_tools: Array<{ id: string; endpoints?: string[] }>
|
||||
): Set<string> {
|
||||
return new Set<string>();
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useSelfHostedToolAvailability } from "@app/hooks/useSelfHostedToolAvailability";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { FileId } from '@app/types/file';
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
|
||||
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown' | 'selfHostedOffline';
|
||||
|
||||
export interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
@@ -28,30 +30,59 @@ interface ToolManagementResult {
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
const isSaaSMode = useSaaSMode();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const toolEndpointList = useMemo(
|
||||
() => (Object.keys(baseRegistry) as ToolId[])
|
||||
// Exclude coming-soon tools (no component and no link) — they are already
|
||||
// unavailable regardless of server state and should not appear in the
|
||||
// self-hosted offline banner.
|
||||
.filter(id => {
|
||||
const tool = baseRegistry[id];
|
||||
return !!(tool?.component ?? tool?.link);
|
||||
})
|
||||
.map(id => ({
|
||||
id,
|
||||
endpoints: baseRegistry[id]?.endpoints ?? [],
|
||||
})),
|
||||
[baseRegistry]
|
||||
);
|
||||
const selfHostedOfflineIds = useSelfHostedToolAvailability(toolEndpointList);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
// Self-hosted offline check must come before the loading gate:
|
||||
// in self-hosted offline mode endpointsLoading stays true indefinitely
|
||||
// (health check never resolves), so checking it first would wrongly
|
||||
// keep all tools enabled.
|
||||
if (selfHostedOfflineIds.has(toolKey)) return false;
|
||||
|
||||
// Keep tools enabled while endpoint status is loading (optimistic UX)
|
||||
if (endpointsLoading) return true;
|
||||
|
||||
const tool = baseRegistry[toolKey as ToolId];
|
||||
const endpoints = tool?.endpoints || [];
|
||||
|
||||
// Tools without endpoints are always available
|
||||
if (endpoints.length === 0) return true;
|
||||
|
||||
// Check if at least one endpoint is enabled
|
||||
// If endpoint is not in status map, assume enabled (optimistic fallback)
|
||||
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
|
||||
// In SaaS mode tools without local support can route to the cloud backend
|
||||
if (!hasLocalSupport && isSaaSMode) return true;
|
||||
|
||||
return hasLocalSupport;
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode, selfHostedOfflineIds]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
if (selfHostedOfflineIds.has(toolKey)) {
|
||||
return 'selfHostedOffline';
|
||||
}
|
||||
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
@@ -71,10 +102,13 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
}, [baseRegistry, endpointDetails, endpointStatus, selfHostedOfflineIds]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
// Skip computation during loading UNLESS some tools are already known offline.
|
||||
// In self-hosted offline mode endpointsLoading never clears, so we must still
|
||||
// compute the map to surface the selfHostedOfflineIds set.
|
||||
if (endpointsLoading && selfHostedOfflineIds.size === 0) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
@@ -85,7 +119,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable, selfHostedOfflineIds]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
@@ -115,7 +149,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
}, [toolRegistry]);
|
||||
|
||||
return {
|
||||
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
|
||||
selectedTool: getSelectedTool(null),
|
||||
toolSelectedFileIds,
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
|
||||
@@ -4,5 +4,5 @@ export interface BackendHealthState {
|
||||
status: BackendStatus;
|
||||
message?: string | null;
|
||||
error: string | null;
|
||||
isHealthy: boolean;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | 'NOT_SUPPORTED_LOCALLY' | null;
|
||||
|
||||
export interface EndpointAvailabilityDetails {
|
||||
enabled: boolean;
|
||||
|
||||
5
frontend/src/core/types/groupEnabled.ts
Normal file
5
frontend/src/core/types/groupEnabled.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface GroupEnabledResult {
|
||||
enabled: boolean | null;
|
||||
/** Human-readable reason shown when the feature is unavailable. Null while loading or when enabled. */
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@@ -64,7 +65,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
// Also start the self-hosted server monitor so the operation router and UI
|
||||
// can detect when the remote server goes offline and fall back to local backend.
|
||||
connectionModeService.getServerConfig().then(cfg => {
|
||||
if (cfg?.url) {
|
||||
selfHostedServerMonitor.start(cfg.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
selfHostedServerMonitor.stop();
|
||||
};
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
@@ -72,38 +83,31 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Preload endpoint availability after backend is healthy
|
||||
// Preload endpoint availability for the local bundled backend.
|
||||
// SaaS mode: triggers when the bundled backend reports healthy.
|
||||
// Self-hosted mode: triggers when the local bundled backend port is discovered
|
||||
// (so useSelfHostedToolAvailability can use the cache instead of making
|
||||
// individual requests per-tool when the remote server goes offline).
|
||||
const shouldPreloadLocalEndpoints =
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
|
||||
useEffect(() => {
|
||||
if (!shouldMonitorBackend) {
|
||||
return; // Only preload in SaaS mode with bundled backend
|
||||
}
|
||||
if (!shouldPreloadLocalEndpoints) return;
|
||||
|
||||
const preloadEndpoints = async () => {
|
||||
const backendHealthy = tauriBackendService.isBackendHealthy();
|
||||
if (backendHealthy) {
|
||||
console.debug('[AppProviders] Preloading common tool endpoints');
|
||||
await endpointAvailabilityService.preloadEndpoints(
|
||||
COMMON_TOOL_ENDPOINTS,
|
||||
tauriBackendService.getBackendUrl()
|
||||
);
|
||||
console.debug('[AppProviders] Endpoint preloading complete');
|
||||
}
|
||||
const tryPreload = () => {
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (!backendUrl) return;
|
||||
// tauriBackendService.isOnline now always reflects the local backend.
|
||||
// Wait for it to be healthy before preloading in both modes.
|
||||
if (!tauriBackendService.isOnline) return;
|
||||
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
|
||||
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
|
||||
};
|
||||
|
||||
// Subscribe to backend status changes
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
preloadEndpoints();
|
||||
}
|
||||
});
|
||||
|
||||
// Also check immediately in case backend is already healthy
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
preloadEndpoints();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
|
||||
tryPreload();
|
||||
return unsubscribe;
|
||||
}, [shouldMonitorBackend]);
|
||||
}, [shouldPreloadLocalEndpoints, connectionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) {
|
||||
|
||||
@@ -13,29 +13,29 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const { status, isHealthy, checkHealth } = useBackendHealth();
|
||||
const { status, isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return t('backendHealth.checking', 'Checking backend status...');
|
||||
}
|
||||
|
||||
if (isHealthy) {
|
||||
if (isOnline) {
|
||||
return t('backendHealth.online', 'Backend Online');
|
||||
}
|
||||
|
||||
return t('backendHealth.offline', 'Backend Offline');
|
||||
}, [status, isHealthy, t]);
|
||||
}, [status, isOnline, t]);
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return theme.colors.yellow?.[5] ?? '#fcc419';
|
||||
}
|
||||
if (isHealthy) {
|
||||
if (isOnline) {
|
||||
return theme.colors.green?.[5] ?? '#37b24d';
|
||||
}
|
||||
return theme.colors.red?.[6] ?? '#e03131';
|
||||
}, [status, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
}, [status, isOnline, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useBanner } from '@app/contexts/BannerContext';
|
||||
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
|
||||
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
|
||||
import { TeamInvitationBanner } from '@app/components/shared/TeamInvitationBanner';
|
||||
import { SelfHostedOfflineBanner } from '@app/components/shared/SelfHostedOfflineBanner';
|
||||
|
||||
export function DesktopBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
@@ -10,6 +11,7 @@ export function DesktopBannerInitializer() {
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<>
|
||||
<SelfHostedOfflineBanner />
|
||||
<TeamInvitationBanner />
|
||||
<UpgradeBanner />
|
||||
<DefaultAppBanner />
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useConversionCloudStatus } from '@app/hooks/useConversionCloudStatus';
|
||||
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
|
||||
import { ENDPOINTS as SPLIT_ENDPOINTS } from '@app/constants/splitConstants';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
|
||||
const BANNER_BG = 'var(--mantine-color-gray-1)';
|
||||
const BANNER_BORDER = 'var(--mantine-color-gray-3)';
|
||||
const BANNER_TEXT = 'var(--mantine-color-gray-7)';
|
||||
const BANNER_ICON = 'var(--mantine-color-gray-5)';
|
||||
const BANNER_LINK = 'var(--mantine-color-gray-6)';
|
||||
|
||||
/** Maps split endpoint → [i18n key, English fallback] for the method name */
|
||||
const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
|
||||
'split-pages': ['split.methods.byPages.name', 'Pages'],
|
||||
'split-pdf-by-sections': ['split.methods.bySections.name', 'Sections'],
|
||||
'split-by-size-or-count': ['split.methods.bySize.name', 'File Size'],
|
||||
'split-pdf-by-chapters': ['split.methods.byChapters.name', 'Chapters'],
|
||||
'auto-split-pdf': ['split.methods.byPageDivider.name', 'Page Divider'],
|
||||
'split-for-poster-print': ['split.methods.byPoster.name', 'Printable Chunks'],
|
||||
};
|
||||
|
||||
/** Conversion endpoint keys that have translations under selfHosted.offline.conversionTypes */
|
||||
const KNOWN_CONVERSION_ENDPOINTS = new Set([
|
||||
'file-to-pdf', 'pdf-to-img', 'img-to-pdf', 'svg-to-pdf', 'cbz-to-pdf',
|
||||
'pdf-to-cbz', 'pdf-to-word', 'pdf-to-presentation', 'pdf-to-text', 'pdf-to-csv',
|
||||
'pdf-to-xlsx', 'pdf-to-markdown', 'pdf-to-html', 'pdf-to-xml', 'pdf-to-pdfa',
|
||||
'html-to-pdf', 'markdown-to-pdf', 'eml-to-pdf', 'cbr-to-pdf', 'pdf-to-cbr',
|
||||
'ebook-to-pdf', 'pdf-to-epub',
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* Desktop-only banner shown when the user is in self-hosted mode and the
|
||||
* configured Stirling-PDF server is unreachable.
|
||||
*
|
||||
* - Warns the user their server is offline
|
||||
* - Explains whether local fallback is active
|
||||
* - Shows an expandable list of tools that are unavailable locally
|
||||
* - Session-dismissable (reappears on next launch if server still offline)
|
||||
*/
|
||||
export function SelfHostedOfflineBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
const [serverState, setServerState] = useState<SelfHostedServerState>(
|
||||
() => selfHostedServerMonitor.getSnapshot()
|
||||
);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [localBackendReady, setLocalBackendReady] = useState(
|
||||
() => !!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
|
||||
// Load connection mode once on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Subscribe to self-hosted server status changes
|
||||
useEffect(() => {
|
||||
const unsub = selfHostedServerMonitor.subscribe(state => {
|
||||
setServerState(state);
|
||||
// Auto-collapse tool list when server comes back online
|
||||
if (state.isOnline) setExpanded(false);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// React to local backend port being discovered
|
||||
useEffect(() => {
|
||||
return tauriBackendService.subscribeToStatus(() => {
|
||||
setLocalBackendReady(!!tauriBackendService.getBackendUrl());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-use the toolAvailability already computed by useToolManagement —
|
||||
// tools with reason 'selfHostedOffline' are the ones unavailable locally.
|
||||
const { toolAvailability, toolRegistry } = useToolWorkflow();
|
||||
|
||||
// Re-use conversion availability already computed by useConversionCloudStatus.
|
||||
const { availability: conversionAvailability } = useConversionCloudStatus();
|
||||
|
||||
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
|
||||
useEffect(() => {
|
||||
if (serverState.status !== 'offline') {
|
||||
setSplitAvailability({});
|
||||
return;
|
||||
}
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (!localUrl) {
|
||||
setSplitAvailability({});
|
||||
return;
|
||||
}
|
||||
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
|
||||
void Promise.all(
|
||||
uniqueEndpoints.map(async ep => ({
|
||||
ep,
|
||||
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
|
||||
}))
|
||||
).then(results => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const { ep, supported } of results) map[ep] = supported;
|
||||
setSplitAvailability(map);
|
||||
});
|
||||
}, [serverState.status]);
|
||||
|
||||
const allUnavailableNames = useMemo(() => {
|
||||
// Top-level tools unavailable in self-hosted offline mode
|
||||
const toolNames = (Object.keys(toolAvailability) as ToolId[])
|
||||
.filter(id => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === 'selfHostedOffline')
|
||||
.map(id => toolRegistry[id]?.name ?? id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Use translated tool names from the registry as prefixes
|
||||
const convertPrefix = toolRegistry['convert' as ToolId]?.name ?? 'Convert';
|
||||
const splitPrefix = toolRegistry['split' as ToolId]?.name ?? 'Split';
|
||||
|
||||
// Conversion types unavailable locally — deduplicated by endpoint
|
||||
const unavailableEndpoints = new Set<string>();
|
||||
for (const [key, available] of Object.entries(conversionAvailability)) {
|
||||
if (!available) {
|
||||
const dashIdx = key.indexOf('-');
|
||||
const fromExt = key.slice(0, dashIdx);
|
||||
const toExt = key.slice(dashIdx + 1);
|
||||
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (endpoint) unavailableEndpoints.add(endpoint);
|
||||
}
|
||||
}
|
||||
const conversionNames = [...unavailableEndpoints]
|
||||
.map(ep => {
|
||||
const suffix = KNOWN_CONVERSION_ENDPOINTS.has(ep)
|
||||
? t(`selfHosted.offline.conversionTypes.${ep}`, ep)
|
||||
: ep;
|
||||
return `${convertPrefix}: ${suffix}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Split methods unavailable locally
|
||||
const unavailableSplitNames = Object.entries(splitAvailability)
|
||||
.filter(([, available]) => !available)
|
||||
.map(([ep]) => {
|
||||
const i18n = SPLIT_ENDPOINT_I18N[ep];
|
||||
const suffix = i18n ? t(i18n[0], i18n[1]) : ep;
|
||||
return `${splitPrefix}: ${suffix}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...toolNames, ...conversionNames, ...unavailableSplitNames].sort();
|
||||
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
|
||||
|
||||
// Only show when in self-hosted mode, server confirmed offline, and not dismissed
|
||||
const show =
|
||||
!dismissed &&
|
||||
connectionMode === 'selfhosted' &&
|
||||
serverState.status === 'offline';
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const messageText = localBackendReady
|
||||
? t('selfHosted.offline.messageWithFallback', 'Some tools require a server connection.')
|
||||
: t('selfHosted.offline.messageNoFallback', 'Tools are unavailable until your server comes back online.');
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius={0}
|
||||
style={{
|
||||
background: BANNER_BG,
|
||||
borderBottom: `1px solid ${BANNER_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap" justify="space-between" px="sm" py={6}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: BANNER_ICON, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ color: BANNER_TEXT, flexShrink: 0 }}>
|
||||
{t('selfHosted.offline.title', 'Server unreachable')}
|
||||
</Text>
|
||||
<Text size="xs" style={{ color: BANNER_TEXT, opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{messageText}
|
||||
</Text>
|
||||
</Group>
|
||||
{allUnavailableNames.length > 0 && (
|
||||
<Popover
|
||||
opened={expanded}
|
||||
onClose={() => setExpanded(false)}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
width={260}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{ color: BANNER_LINK, fontSize: 'var(--mantine-font-size-xs)', fontWeight: 500, flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{expanded
|
||||
? t('selfHosted.offline.hideTools', 'Hide unavailable tools ▴')
|
||||
: t('selfHosted.offline.showTools', 'View unavailable tools ▾')}
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<List size="xs" spacing={2}>
|
||||
{allUnavailableNames.map(name => (
|
||||
<List.Item key={name}>{name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('close', 'Close')}
|
||||
style={{ color: BANNER_TEXT }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,63 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { backendHealthMonitor } from '@app/services/backendHealthMonitor';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import type { BackendHealthState } from '@app/types/backendHealth';
|
||||
|
||||
/**
|
||||
* Hook to read the shared backend health monitor state.
|
||||
* All consumers subscribe to a single poller managed by backendHealthMonitor.
|
||||
* Hook to read backend health state for UI (Run button, BackendHealthIndicator).
|
||||
*
|
||||
* backendHealthMonitor tracks the local bundled backend.
|
||||
* selfHostedServerMonitor tracks the remote server in self-hosted mode.
|
||||
*
|
||||
* isOnline logic:
|
||||
* - SaaS mode: true when local backend is healthy
|
||||
* - Self-hosted mode (server online): true (remote is up)
|
||||
* - Self-hosted mode (server offline, local port known): true so the Run button
|
||||
* stays enabled — operationRouter routes supported tools to local
|
||||
* - Self-hosted mode (server offline, local port unknown): false
|
||||
*/
|
||||
export function useBackendHealth() {
|
||||
const [health, setHealth] = useState<BackendHealthState>(() => backendHealthMonitor.getSnapshot());
|
||||
const [serverStatus, setServerStatus] = useState(
|
||||
() => selfHostedServerMonitor.getSnapshot().status
|
||||
);
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(
|
||||
() => tauriBackendService.getBackendUrl()
|
||||
);
|
||||
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return backendHealthMonitor.subscribe(setHealth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return selfHostedServerMonitor.subscribe(state => setServerStatus(state.status));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return tauriBackendService.subscribeToStatus(() => {
|
||||
setLocalUrl(tauriBackendService.getBackendUrl());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
return backendHealthMonitor.checkNow();
|
||||
}, []);
|
||||
|
||||
const isOnline =
|
||||
connectionMode === 'selfhosted'
|
||||
? serverStatus !== 'offline' || !!localUrl
|
||||
: health.isOnline;
|
||||
|
||||
return {
|
||||
...health,
|
||||
isOnline,
|
||||
checkHealth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
|
||||
import { getEndpointName } from '@app/utils/convertUtils';
|
||||
|
||||
@@ -28,16 +29,56 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
|
||||
useEffect(() => {
|
||||
const checkConversions = async () => {
|
||||
// Don't check until backend is healthy
|
||||
// This prevents showing incorrect status during startup
|
||||
if (!tauriBackendService.isBackendHealthy()) {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Self-hosted offline path: server is down but local backend is available.
|
||||
// Check each conversion against the local backend only (no cloud routing).
|
||||
if (mode === 'selfhosted') {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (status === 'offline' && localUrl) {
|
||||
const pairs: [string, string, string][] = [];
|
||||
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
|
||||
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
|
||||
const endpointName = getEndpointName(fromExt, toExt);
|
||||
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
|
||||
}
|
||||
}
|
||||
const availability: Record<string, boolean> = {};
|
||||
const cloudStatus: Record<string, boolean> = {};
|
||||
const localOnly: Record<string, boolean> = {};
|
||||
const results = await Promise.all(
|
||||
pairs.map(async ([fromExt, toExt, endpointName]) => {
|
||||
const key = `${fromExt}-${toExt}`;
|
||||
try {
|
||||
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(endpointName, localUrl);
|
||||
return { key, supported };
|
||||
} catch {
|
||||
return { key, supported: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const { key, supported } of results) {
|
||||
availability[key] = supported;
|
||||
cloudStatus[key] = false;
|
||||
localOnly[key] = supported;
|
||||
}
|
||||
setStatus({ availability, cloudStatus, localOnly });
|
||||
return;
|
||||
}
|
||||
// Server online or local not ready: let normal endpoint checking handle it
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't check until backend is healthy (SaaS startup guard)
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== 'saas') {
|
||||
// In non-SaaS modes, local endpoint checking handles everything
|
||||
// Non-SaaS, non-self-hosted: local endpoint checking handles everything
|
||||
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
|
||||
return;
|
||||
}
|
||||
@@ -83,14 +124,29 @@ export function useConversionCloudStatus(): ConversionStatus {
|
||||
// Initial check
|
||||
checkConversions();
|
||||
|
||||
// Subscribe to backend status changes to re-check when backend becomes healthy
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
// Re-check when SaaS local backend becomes healthy
|
||||
const unsubLocal = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
checkConversions();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
// Re-check when self-hosted server goes offline or comes back online.
|
||||
// By the time the server is confirmed offline, the local port is already
|
||||
// discovered (waitForPort completes in ~500ms vs the 8s server poll timeout).
|
||||
// selfHostedServerMonitor.subscribe immediately invokes the listener with the
|
||||
// current state, which would cause a duplicate check alongside the one above.
|
||||
// Skip the first invocation since checkConversions() was already called above.
|
||||
let skipFirst = true;
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (skipFirst) { skipFirst = false; return; }
|
||||
void checkConversions();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubLocal();
|
||||
unsubServer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { isAxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
@@ -15,6 +17,13 @@ interface EndpointConfig {
|
||||
|
||||
const RETRY_DELAY_MS = 2500;
|
||||
|
||||
function isSelfHostedOffline(): boolean {
|
||||
return (
|
||||
selfHostedServerMonitor.getSnapshot().status === 'offline' &&
|
||||
!!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (isAxiosError(err)) {
|
||||
const data = err.response?.data as { message?: string } | undefined;
|
||||
@@ -146,7 +155,21 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
// In self-hosted offline mode, enable optimistically when the local backend is ready.
|
||||
// ConvertSettings already filters unsupported endpoints from the dropdown,
|
||||
// so by the time the user has a valid endpoint selected it is supported locally.
|
||||
if (isSelfHostedOffline()) {
|
||||
setEnabled(true);
|
||||
setLoading(false);
|
||||
// Re-evaluate if the server comes back online
|
||||
return selfHostedServerMonitor.subscribe(() => {
|
||||
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (tauriBackendService.isOnline) {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
|
||||
@@ -207,6 +230,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-hosted offline: check each endpoint against the local backend directly.
|
||||
// checkDependenciesReady() would fail here since it hits the offline remote server.
|
||||
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (serverStatus === 'offline' && localUrl) {
|
||||
const results = await Promise.all(
|
||||
[...new Set(endpoints)].map(async (ep) => {
|
||||
try {
|
||||
const supported = await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl);
|
||||
return { ep, supported };
|
||||
} catch {
|
||||
return { ep, supported: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
const statusMap: Record<string, boolean> = {};
|
||||
const details: Record<string, EndpointAvailabilityDetails> = {};
|
||||
for (const { ep, supported } of results) {
|
||||
statusMap[ep] = supported;
|
||||
details[ep] = { enabled: supported, reason: supported ? null : 'NOT_SUPPORTED_LOCALLY' };
|
||||
}
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
@@ -313,7 +364,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
if (isSelfHostedOffline()) {
|
||||
fetchAllEndpointStatuses();
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
return unsubServer;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
|
||||
|
||||
44
frontend/src/desktop/hooks/useGroupEnabled.ts
Normal file
44
frontend/src/desktop/hooks/useGroupEnabled.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import type { GroupEnabledResult } from '@app/types/groupEnabled';
|
||||
|
||||
const OFFLINE_REASON_FALLBACK = 'Requires your Stirling-PDF server (currently offline)';
|
||||
|
||||
/**
|
||||
* Desktop override: skips the network request entirely when the self-hosted
|
||||
* server is confirmed offline, returning a reason string matching the tool panel.
|
||||
*/
|
||||
export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
const { t } = useTranslation();
|
||||
// Initialise synchronously so the first render already reflects offline state —
|
||||
// avoids a flash where the option appears enabled before the effect runs.
|
||||
// Use OFFLINE_REASON_FALLBACK directly so unavailableReason is non-null from
|
||||
// the very first render when offline (t() is not available in useState initialiser).
|
||||
const [result, setResult] = useState<GroupEnabledResult>(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === 'offline') {
|
||||
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
|
||||
}
|
||||
return { enabled: null, unavailableReason: null };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === 'offline') {
|
||||
setResult({
|
||||
enabled: false,
|
||||
unavailableReason: t('toolPanel.fullscreen.selfHostedOffline', OFFLINE_REASON_FALLBACK),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
apiClient
|
||||
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
|
||||
.then(res => setResult({ enabled: res.data, unavailableReason: null }))
|
||||
.catch(() => setResult({ enabled: false, unavailableReason: null }));
|
||||
}, [group, t]);
|
||||
|
||||
return result;
|
||||
}
|
||||
18
frontend/src/desktop/hooks/useSaaSMode.ts
Normal file
18
frontend/src/desktop/hooks/useSaaSMode.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
/**
|
||||
* Returns whether the app is currently in SaaS connection mode.
|
||||
* Starts optimistically true (most common for desktop) to avoid tools
|
||||
* being incorrectly marked unavailable during initial load.
|
||||
*/
|
||||
export function useSaaSMode(): boolean {
|
||||
const [isSaaSMode, setIsSaaSMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(mode => setIsSaaSMode(mode === 'saas'));
|
||||
return connectionModeService.subscribeToModeChanges(cfg => setIsSaaSMode(cfg.mode === 'saas'));
|
||||
}, []);
|
||||
|
||||
return isSaaSMode;
|
||||
}
|
||||
92
frontend/src/desktop/hooks/useSelfHostedToolAvailability.ts
Normal file
92
frontend/src/desktop/hooks/useSelfHostedToolAvailability.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
|
||||
/**
|
||||
* Desktop implementation of useSelfHostedToolAvailability.
|
||||
* Returns the set of tool IDs that are unavailable when the self-hosted server
|
||||
* is offline (tools whose endpoints are not supported by the local bundled backend).
|
||||
*
|
||||
* Returns an empty set when:
|
||||
* - Not in self-hosted mode
|
||||
* - Self-hosted server is online
|
||||
* - Local backend port is not yet known
|
||||
*/
|
||||
export function useSelfHostedToolAvailability(
|
||||
tools: Array<{ id: string; endpoints?: string[] }>
|
||||
): Set<string> {
|
||||
const [unavailableIds, setUnavailableIds] = useState<Set<string>>(new Set());
|
||||
// Keep a stable ref to the latest tools list to avoid unnecessary re-subscriptions
|
||||
const toolsRef = useRef(tools);
|
||||
toolsRef.current = tools;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const computeUnavailableTools = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== 'selfhosted') {
|
||||
setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status !== 'offline') {
|
||||
// Idle or checking — not yet confirmed offline; don't mark anything unavailable
|
||||
if (!cancelled) setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (!localUrl) {
|
||||
// Local backend port not yet known; can't determine unavailable tools yet
|
||||
if (!cancelled) setUnavailableIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// For each tool, check whether at least one of its endpoints is supported locally
|
||||
const unavailable = new Set<string>();
|
||||
await Promise.all(
|
||||
toolsRef.current.map(async tool => {
|
||||
const endpoints = tool.endpoints ?? [];
|
||||
if (endpoints.length === 0) return; // No endpoints → always available
|
||||
|
||||
const locallySupported = await Promise.all(
|
||||
endpoints.map(ep =>
|
||||
endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl)
|
||||
)
|
||||
);
|
||||
|
||||
if (!locallySupported.some(Boolean)) {
|
||||
unavailable.add(tool.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!cancelled) setUnavailableIds(unavailable);
|
||||
};
|
||||
|
||||
// Re-compute when server status changes
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
void computeUnavailableTools();
|
||||
});
|
||||
|
||||
// Re-compute when local backend becomes healthy (port discovered)
|
||||
const unsubBackend = tauriBackendService.subscribeToStatus(() => {
|
||||
void computeUnavailableTools();
|
||||
});
|
||||
|
||||
// Initial computation
|
||||
void computeUnavailableTools();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubServer();
|
||||
unsubBackend();
|
||||
};
|
||||
}, []); // tools intentionally omitted — accessed via ref to avoid churn
|
||||
|
||||
return unavailableIds;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function useToolCloudStatus(endpointName?: string): boolean {
|
||||
try {
|
||||
// Don't show cloud badges until backend is healthy
|
||||
// This prevents showing incorrect cloud status during startup
|
||||
if (!tauriBackendService.isBackendHealthy()) {
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setUsesCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { FileId } from '@app/types/file';
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
|
||||
|
||||
export interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
reason?: ToolDisableCause;
|
||||
}
|
||||
|
||||
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolSelectedFileIds: FileId[];
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop override of useToolManagement
|
||||
* Enhances tool availability logic to consider SaaS backend routing
|
||||
* - Tools not supported locally but available on SaaS are marked as available
|
||||
* - In SaaS mode, tools can route to cloud backend
|
||||
*/
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
|
||||
// Start optimistically assuming SaaS mode (most common for desktop)
|
||||
// This prevents tools from being incorrectly marked unavailable during initial load
|
||||
const [isSaaSMode, setIsSaaSMode] = useState(true);
|
||||
|
||||
// Log that desktop version is being used
|
||||
useEffect(() => {
|
||||
console.debug('[useToolManagement] DESKTOP VERSION loaded - SaaS routing enabled');
|
||||
}, []);
|
||||
|
||||
// Check connection mode
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentMode().then(mode => {
|
||||
setIsSaaSMode(mode === 'saas');
|
||||
console.debug('[useToolManagement] Connection mode loaded:', mode);
|
||||
});
|
||||
|
||||
// Subscribe to mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(config => {
|
||||
setIsSaaSMode(config.mode === 'saas');
|
||||
console.debug('[useToolManagement] Connection mode changed:', config.mode);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
if (endpointsLoading) return true;
|
||||
|
||||
const tool = baseRegistry[toolKey as ToolId];
|
||||
const endpoints = tool?.endpoints || [];
|
||||
|
||||
// Tools without endpoints are always available
|
||||
if (endpoints.length === 0) return true;
|
||||
|
||||
// Check if at least one endpoint is enabled locally
|
||||
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, tools are available even if not supported locally
|
||||
// They will route to the SaaS backend instead
|
||||
if (!hasLocalSupport && isSaaSMode) {
|
||||
console.debug(`[useToolManagement] Tool ${toolKey} not supported locally but available via SaaS routing`);
|
||||
// In SaaS mode, assume tools can route to cloud if not available locally
|
||||
// The operation router will handle the actual routing decision
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasLocalSupport) {
|
||||
console.debug(`[useToolManagement] Tool ${toolKey} not available - no local support and not in SaaS mode`, {
|
||||
isSaaSMode,
|
||||
endpoints,
|
||||
endpointStatus: endpoints.map(e => ({ [e]: endpointStatus[e] }))
|
||||
});
|
||||
}
|
||||
|
||||
return hasLocalSupport;
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
}
|
||||
const endpoints = tool.endpoints || [];
|
||||
const disabledReasons: EndpointDisableReason[] = endpoints
|
||||
.filter(endpoint => endpointStatus[endpoint] === false)
|
||||
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
|
||||
|
||||
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (disabledReasons.some(reason => reason === 'CONFIG')) {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
if (disabledReasons.length > 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const available = isToolAvailable(toolKey);
|
||||
availability[toolKey] = available
|
||||
? { available: true }
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (!baseTool) return;
|
||||
const availabilityInfo = toolAvailability[toolKey];
|
||||
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
}
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
}, [toolRegistry]);
|
||||
|
||||
return {
|
||||
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
|
||||
toolSelectedFileIds,
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
@@ -19,7 +19,7 @@ export function useWillUseCloud(endpoint?: string): boolean {
|
||||
|
||||
// Don't show cloud badges until backend is healthy
|
||||
// This prevents showing incorrect cloud status during startup
|
||||
if (!tauriBackendService.isBackendHealthy()) {
|
||||
if (!tauriBackendService.isOnline) {
|
||||
setWillUseCloud(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
const isSaaS = await operationRouter.isSaaSMode();
|
||||
const backendHealthy = tauriBackendService.isBackendHealthy();
|
||||
const backendHealthy = tauriBackendService.isOnline;
|
||||
const backendStatus = tauriBackendService.getBackendStatus();
|
||||
const backendPort = tauriBackendService.getBackendPort();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class BackendHealthMonitor {
|
||||
private state: BackendHealthState = {
|
||||
status: tauriBackendService.getBackendStatus(),
|
||||
error: null,
|
||||
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
|
||||
isOnline: tauriBackendService.getBackendStatus() === 'healthy',
|
||||
};
|
||||
|
||||
constructor(pollingInterval = 5000) {
|
||||
@@ -35,7 +35,7 @@ class BackendHealthMonitor {
|
||||
...this.state,
|
||||
...partial,
|
||||
status: nextStatus,
|
||||
isHealthy: nextStatus === 'healthy',
|
||||
isOnline: nextStatus === 'healthy',
|
||||
};
|
||||
|
||||
// Only notify listeners if meaningful state changed
|
||||
|
||||
@@ -2,14 +2,22 @@ import i18n from '@app/i18n';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { operationRouter } from '@app/services/operationRouter';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
|
||||
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.
|
||||
* Enhanced to skip checks for endpoints routed to SaaS backend.
|
||||
* Desktop-specific guard that ensures the relevant backend is ready before
|
||||
* tools attempt to call any API endpoints.
|
||||
*
|
||||
* - SaaS mode: checks the local bundled backend via tauriBackendService.isOnline
|
||||
* - Self-hosted mode (server online/checking): allows through — the operation
|
||||
* targets the remote server and will surface network errors naturally
|
||||
* - Self-hosted mode (server confirmed offline): allows through if local port is
|
||||
* known (operationRouter falls back to local); suppresses toast since
|
||||
* SelfHostedOfflineBanner already communicates the outage
|
||||
*
|
||||
* @param endpoint - Optional endpoint path to check if it needs local backend
|
||||
* @returns true if backend is ready OR endpoint will be routed to SaaS
|
||||
@@ -24,14 +32,40 @@ export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check local backend health
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'selfhosted') {
|
||||
let { status } = selfHostedServerMonitor.getSnapshot();
|
||||
|
||||
// 'checking' means the first poll hasn't returned yet. Wait briefly (up to
|
||||
// 1.5 s) for it to resolve so we don't surface raw network errors during the
|
||||
// first few seconds after launch. If it doesn't resolve in time we fall
|
||||
// through and allow the operation — the HTTP layer will handle any error.
|
||||
if (status === 'checking') {
|
||||
await Promise.race([
|
||||
selfHostedServerMonitor.checkNow(),
|
||||
new Promise<void>(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
status = selfHostedServerMonitor.getSnapshot().status;
|
||||
}
|
||||
|
||||
if (status === 'offline') {
|
||||
// Server offline: allow through if local backend port is known.
|
||||
// operationRouter will route to local for supported endpoints.
|
||||
// Suppress the toast — SelfHostedOfflineBanner communicates the outage.
|
||||
return !!tauriBackendService.getBackendUrl();
|
||||
}
|
||||
// Server online: allow through — the operation targets the remote server.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trigger a health check so we get the freshest status
|
||||
// SaaS mode: check local bundled backend
|
||||
if (tauriBackendService.isOnline) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trigger a fresh check so we get the latest status
|
||||
await tauriBackendService.checkBackendHealth();
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
if (tauriBackendService.isOnline) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import i18n from '@app/i18n';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
|
||||
import { CONVERSION_ENDPOINTS, ENDPOINT_NAMES } from '@app/constants/convertConstants';
|
||||
|
||||
@@ -181,6 +183,35 @@ export class OperationRouter {
|
||||
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
|
||||
}
|
||||
|
||||
// Self-hosted fallback: when the remote server is offline, route tool endpoints
|
||||
// to the local bundled backend if it supports them.
|
||||
if (mode === 'selfhosted' && operation && this.isToolEndpoint(operation)) {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === 'offline') {
|
||||
const endpointName = this.extractEndpointName(operation);
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (localUrl) {
|
||||
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpointName,
|
||||
localUrl
|
||||
);
|
||||
if (supportedLocally) {
|
||||
console.debug(
|
||||
`[operationRouter] Self-hosted server offline, routing ${operation} to local backend`
|
||||
);
|
||||
return localUrl.replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
i18n.t(
|
||||
'selfHosted.offline.toolNotAvailableLocally',
|
||||
'Your Stirling-PDF server is offline and "{{endpoint}}" is not available on the local backend.',
|
||||
{ endpoint: endpointName }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Existing logic for local/remote routing
|
||||
const target = await this.getExecutionTarget(operation);
|
||||
|
||||
|
||||
133
frontend/src/desktop/services/selfHostedServerMonitor.ts
Normal file
133
frontend/src/desktop/services/selfHostedServerMonitor.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export interface SelfHostedServerState {
|
||||
status: 'idle' | 'checking' | 'online' | 'offline';
|
||||
isOnline: boolean;
|
||||
serverUrl: string | null;
|
||||
}
|
||||
|
||||
type Listener = (state: SelfHostedServerState) => void;
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
const REQUEST_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* Singleton service that independently monitors the health of the self-hosted
|
||||
* Stirling-PDF server. Used in self-hosted connection mode to detect when the
|
||||
* remote server goes offline so that the operation router can fall back to the
|
||||
* local bundled backend for supported tools.
|
||||
*
|
||||
* This is separate from tauriBackendService / backendHealthMonitor so that the
|
||||
* local-backend health indicator (BackendHealthIndicator) and the self-hosted
|
||||
* server status can be tracked independently.
|
||||
*/
|
||||
class SelfHostedServerMonitor {
|
||||
private static instance: SelfHostedServerMonitor;
|
||||
|
||||
private state: SelfHostedServerState = {
|
||||
status: 'idle',
|
||||
isOnline: false,
|
||||
serverUrl: null,
|
||||
};
|
||||
|
||||
private listeners = new Set<Listener>();
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
static getInstance(): SelfHostedServerMonitor {
|
||||
if (!SelfHostedServerMonitor.instance) {
|
||||
SelfHostedServerMonitor.instance = new SelfHostedServerMonitor();
|
||||
}
|
||||
return SelfHostedServerMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling the given server URL for health.
|
||||
* Safe to call multiple times; only starts one polling loop at a time.
|
||||
* Call stop() before calling start() again with a different URL.
|
||||
*/
|
||||
start(serverUrl: string): void {
|
||||
if (this.intervalId !== null && this.state.serverUrl === serverUrl) {
|
||||
// Already polling this URL
|
||||
return;
|
||||
}
|
||||
|
||||
this.stop();
|
||||
|
||||
this.updateState({ serverUrl, status: 'checking', isOnline: false });
|
||||
|
||||
void this.pollOnce(serverUrl);
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.pollOnce(serverUrl);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop polling. */
|
||||
stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.updateState({ status: 'idle', isOnline: false, serverUrl: null });
|
||||
}
|
||||
|
||||
get isOnline(): boolean {
|
||||
return this.state.isOnline;
|
||||
}
|
||||
|
||||
getSnapshot(): SelfHostedServerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Subscribe to state changes. Returns an unsubscribe function. */
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
// Emit current state immediately so subscribers are always initialised
|
||||
listener(this.state);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Trigger an immediate health check outside of the scheduled interval. */
|
||||
async checkNow(): Promise<void> {
|
||||
if (this.state.serverUrl) {
|
||||
await this.pollOnce(this.state.serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOnce(serverUrl: string): Promise<void> {
|
||||
const healthUrl = `${serverUrl.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.updateState({ status: 'online', isOnline: true });
|
||||
} else {
|
||||
this.updateState({ status: 'offline', isOnline: false });
|
||||
}
|
||||
} catch {
|
||||
this.updateState({ status: 'offline', isOnline: false });
|
||||
}
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<SelfHostedServerState>): void {
|
||||
const next = { ...this.state, ...partial };
|
||||
|
||||
const changed =
|
||||
next.status !== this.state.status ||
|
||||
next.isOnline !== this.state.isOnline ||
|
||||
next.serverUrl !== this.state.serverUrl;
|
||||
|
||||
this.state = next;
|
||||
|
||||
if (changed) {
|
||||
this.listeners.forEach(listener => listener(this.state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const selfHostedServerMonitor = SelfHostedServerMonitor.getInstance();
|
||||
@@ -1,7 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { getAuthTokenFromAnySource } from '@app/services/authTokenStore';
|
||||
|
||||
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||
|
||||
@@ -29,7 +27,7 @@ export class TauriBackendService {
|
||||
return this.backendStatus;
|
||||
}
|
||||
|
||||
isBackendHealthy(): boolean {
|
||||
get isOnline(): boolean {
|
||||
return this.backendStatus === 'healthy';
|
||||
}
|
||||
|
||||
@@ -58,7 +56,9 @@ export class TauriBackendService {
|
||||
|
||||
/**
|
||||
* Initialize health monitoring for an external server (server mode)
|
||||
* Does not start bundled backend, but enables health checks
|
||||
* Does not start bundled backend, but enables health checks.
|
||||
* Also discovers the local bundled backend port so it can be used as a fallback
|
||||
* when the self-hosted server is offline.
|
||||
*/
|
||||
async initializeExternalBackend(): Promise<void> {
|
||||
if (this.backendStarted) {
|
||||
@@ -68,6 +68,11 @@ export class TauriBackendService {
|
||||
this.backendStarted = true; // Mark as active for health checks
|
||||
this.setStatus('starting');
|
||||
this.beginHealthMonitoring();
|
||||
|
||||
// Discover the local bundled backend port in the background.
|
||||
// The Rust side always starts the local backend, so we can poll for its port
|
||||
// even in self-hosted mode. This allows local fallback when the server is offline.
|
||||
void this.waitForPort();
|
||||
}
|
||||
|
||||
async startBackend(backendUrl?: string): Promise<void> {
|
||||
@@ -108,6 +113,8 @@ export class TauriBackendService {
|
||||
const port = await invoke<number | null>('get_backend_port');
|
||||
if (port) {
|
||||
this.backendPort = port;
|
||||
// Notify status listeners so hooks reading getBackendUrl() re-evaluate
|
||||
this.statusListeners.forEach(listener => listener(this.backendStatus));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -117,19 +124,6 @@ export class TauriBackendService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth token with expiry validation
|
||||
* Delegates to authService which handles caching and expiry checking
|
||||
*/
|
||||
private async getAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
return await getAuthTokenFromAnySource();
|
||||
} catch (error) {
|
||||
console.debug('[TauriBackendService] Failed to get auth token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private beginHealthMonitoring() {
|
||||
if (this.healthMonitor) {
|
||||
return;
|
||||
@@ -143,67 +137,31 @@ export class TauriBackendService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Always checks the local bundled backend at localhost:{port}. */
|
||||
async checkBackendHealth(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Determine base URL based on mode
|
||||
let baseUrl: string;
|
||||
if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
baseUrl = serverConfig.url.replace(/\/$/, '');
|
||||
} else {
|
||||
// SaaS mode - check bundled local backend
|
||||
if (!this.backendStarted) {
|
||||
console.debug('[TauriBackendService] Health check: backend not started');
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
if (!this.backendPort) {
|
||||
console.debug('[TauriBackendService] Health check: backend port not available');
|
||||
return false;
|
||||
}
|
||||
baseUrl = `http://localhost:${this.backendPort}`;
|
||||
if (!this.backendStarted) {
|
||||
console.debug('[TauriBackendService] Health check: backend not started');
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
if (!this.backendPort) {
|
||||
console.debug('[TauriBackendService] Health check: backend port not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if backend is ready (dependencies checked)
|
||||
const configUrl = `http://localhost:${this.backendPort}/api/v1/config/app-config`;
|
||||
console.debug(`[TauriBackendService] Checking local backend health at: ${configUrl}`);
|
||||
|
||||
try {
|
||||
const configUrl = `${baseUrl}/api/v1/config/app-config`;
|
||||
console.debug(`[TauriBackendService] Checking backend health at: ${configUrl}`);
|
||||
|
||||
// For self-hosted mode, include auth token if available
|
||||
const headers: Record<string, string> = {};
|
||||
if (mode === 'selfhosted') {
|
||||
const token = await this.getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
console.debug(`[TauriBackendService] Adding auth token to health check`);
|
||||
} else {
|
||||
console.debug(`[TauriBackendService] No auth token available for health check`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(configUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 5000,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.debug(`[TauriBackendService] Health check response: status=${response.status}, ok=${response.ok}`);
|
||||
const response = await fetch(configUrl, { method: 'GET', connectTimeout: 5000 });
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[TauriBackendService] Health check failed: response not ok (status ${response.status})`);
|
||||
console.warn(`[TauriBackendService] Health check failed: ${response.status}`);
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.debug(`[TauriBackendService] Health check data:`, data);
|
||||
|
||||
const dependenciesReady = data.dependenciesReady === true;
|
||||
console.debug(`[TauriBackendService] dependenciesReady=${dependenciesReady}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user