mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(desktop): move endpoint availability onto TanStack Query
The desktop shadow of useEndpointConfig hand-rolled dedupe, a 2.5s backend-starting retry loop, and health-monitor-driven refetches across two subscriptions. It now runs on the same TanStack Query foundation as the web version (#7285), keeping every behaviour the characterisation tests pin down. The domain logic that is not caching moves into desktop/api/endpointAvailability as plain resolve functions: the self-hosted-offline per-endpoint local check, the legacy 400 query-param fallback, SaaS-mode optimism, and the fail-closed map. The dependency-ready gate becomes a retryable BACKEND_NOT_READY error, so Query's retry replaces the manual setTimeout loop. Backend readiness is tracked with useSyncExternalStore (the pattern desktop useGroupEnabled established), so the query wakes when the backend comes up; a readiness change invalidates the cache, which is how a reconnect swaps the offline answer for the live one. The hook shrinks from 485 lines to ~180, and the two return shapes are unchanged, so no consumer is touched.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { isAxiosError } from "axios";
|
||||
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 { connectionModeService } from "@app/services/connectionModeService";
|
||||
import {
|
||||
createBackendNotReadyError,
|
||||
isBackendNotReadyError,
|
||||
} from "@app/constants/backendErrors";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
export type EndpointAvailabilityMap = Record<
|
||||
string,
|
||||
EndpointAvailabilityDetails
|
||||
>;
|
||||
|
||||
/** Self-hosted server down, but a local bundled backend is reachable. */
|
||||
export function isSelfHostedOffline(): boolean {
|
||||
return (
|
||||
selfHostedServerMonitor.getSnapshot().status === "offline" &&
|
||||
!!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every remote check on the backend reporting its dependencies ready.
|
||||
* Not-ready surfaces as a retryable error so the query retries rather than
|
||||
* caching a premature answer. {@link isBackendNotReadyError} also matches the
|
||||
* backend-starting error the fetch itself can throw, so both share one retry.
|
||||
*/
|
||||
async function ensureDependenciesReady(): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>(
|
||||
"/api/v1/config/app-config",
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
if (response.data?.dependenciesReady) return;
|
||||
} catch {
|
||||
// Unreachable app-config is itself "not ready yet".
|
||||
}
|
||||
throw createBackendNotReadyError();
|
||||
}
|
||||
|
||||
/** New servers return the whole map for a bare call; old ones need the param. */
|
||||
async function fetchAvailabilityMap(
|
||||
endpoints: string[],
|
||||
): Promise<EndpointAvailabilityMap> {
|
||||
try {
|
||||
const response = await apiClient.get<EndpointAvailabilityMap>(
|
||||
"/api/v1/config/endpoints-availability",
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === 400) {
|
||||
const param = encodeURIComponent(endpoints.join(","));
|
||||
const response = await apiClient.get<EndpointAvailabilityMap>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${param}`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalise(map: EndpointAvailabilityMap): EndpointAvailabilityMap {
|
||||
const out: EndpointAvailabilityMap = {};
|
||||
for (const [name, detail] of Object.entries(map)) {
|
||||
out[name] = {
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SaaS routing covers a locally-disabled endpoint, so mark it available. */
|
||||
function applySaasOptimism(
|
||||
map: EndpointAvailabilityMap,
|
||||
): EndpointAvailabilityMap {
|
||||
const out: EndpointAvailabilityMap = { ...map };
|
||||
for (const [name, detail] of Object.entries(out)) {
|
||||
if (!detail.enabled) out[name] = { enabled: true, reason: null };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Self-hosted offline: ask the local backend about each endpoint directly. */
|
||||
async function resolveOffline(
|
||||
endpoints: string[],
|
||||
): Promise<EndpointAvailabilityMap> {
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
const results = await Promise.all(
|
||||
[...new Set(endpoints)].map(async (endpoint) => {
|
||||
try {
|
||||
const supported =
|
||||
await endpointAvailabilityService.isEndpointSupportedLocally(
|
||||
endpoint,
|
||||
localUrl,
|
||||
);
|
||||
return [endpoint, supported] as const;
|
||||
} catch {
|
||||
return [endpoint, false] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const map: EndpointAvailabilityMap = {};
|
||||
for (const [endpoint, supported] of results) {
|
||||
map[endpoint] = {
|
||||
enabled: supported,
|
||||
reason: supported ? null : "NOT_SUPPORTED_LOCALLY",
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole availability map for the current environment. Fail-closed on a
|
||||
* fetch error outside SaaS mode (each requested endpoint disabled), matching
|
||||
* desktop's stance that an unknown local capability is unavailable — the
|
||||
* opposite of the web fail-open, and the reason this is a desktop shadow.
|
||||
*/
|
||||
export async function resolveEndpointsAvailability(
|
||||
endpoints: string[],
|
||||
): Promise<EndpointAvailabilityMap> {
|
||||
if (isSelfHostedOffline()) {
|
||||
return resolveOffline(endpoints);
|
||||
}
|
||||
|
||||
await ensureDependenciesReady();
|
||||
const saas = (await connectionModeService.getCurrentMode()) === "saas";
|
||||
|
||||
try {
|
||||
const map = normalise(await fetchAvailabilityMap(endpoints));
|
||||
return saas ? applySaasOptimism(map) : map;
|
||||
} catch (error) {
|
||||
if (isBackendNotReadyError(error)) throw error;
|
||||
const fallback: EndpointAvailabilityMap = {};
|
||||
for (const endpoint of endpoints) {
|
||||
fallback[endpoint] = saas
|
||||
? { enabled: true, reason: null }
|
||||
: { enabled: false, reason: "UNKNOWN" };
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one endpoint is enabled, with the same SaaS optimism. */
|
||||
export async function resolveEndpointEnabled(
|
||||
endpoint: string,
|
||||
): Promise<boolean> {
|
||||
if (isSelfHostedOffline()) {
|
||||
// ConvertSettings already filters unsupported endpoints from the dropdown,
|
||||
// so a selected endpoint is supported locally by the time it reaches here.
|
||||
return true;
|
||||
}
|
||||
|
||||
await ensureDependenciesReady();
|
||||
const saas = (await connectionModeService.getCurrentMode()) === "saas";
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
return response.data || saas;
|
||||
} catch (error) {
|
||||
if (isBackendNotReadyError(error)) throw error;
|
||||
return saas;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import {
|
||||
@@ -32,7 +40,9 @@ vi.mock("@app/i18n", () => ({
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
const mockGet = vi.mocked(apiClient.get);
|
||||
// Cast to a plain mock: the real get() is the tauri-http signature, but the
|
||||
// tests only need { data } back.
|
||||
const mockGet = apiClient.get as unknown as Mock;
|
||||
|
||||
// --- connection mode ---
|
||||
let mode: "saas" | "selfhosted" | "local" = "saas";
|
||||
@@ -282,10 +292,15 @@ describe("desktop useMultipleEndpointsEnabled", () => {
|
||||
return Promise.resolve(availability({ merge: { enabled: true } }));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultipleEndpointsEnabled(["merge"]), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
await waitFor(() => expect(result.current.endpointStatus.merge).toBe(false));
|
||||
const { result } = renderHook(
|
||||
() => useMultipleEndpointsEnabled(["merge"]),
|
||||
{
|
||||
wrapper: TestQueryProvider,
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.endpointStatus.merge).toBe(false),
|
||||
);
|
||||
|
||||
// Server comes back: the hook must re-resolve against the remote, not sit
|
||||
// on the stale offline answer.
|
||||
|
||||
@@ -1,203 +1,96 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
|
||||
import {
|
||||
isSelfHostedOffline,
|
||||
resolveEndpointEnabled,
|
||||
resolveEndpointsAvailability,
|
||||
} from "@app/api/endpointAvailability";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 2500;
|
||||
const OPTIMISTIC: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
|
||||
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;
|
||||
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";
|
||||
}
|
||||
|
||||
async function checkDependenciesReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>(
|
||||
"/api/v1/config/app-config",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
return response.data?.dependenciesReady ?? false;
|
||||
} catch (error) {
|
||||
console.debug("[useEndpointConfig] Dependencies not ready yet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Booleans, not the monitors' state objects — those are reassigned every poll,
|
||||
// which would defeat useSyncExternalStore's identity check.
|
||||
const subscribeReadiness = (onChange: () => void) => {
|
||||
const unsubBackend = tauriBackendService.subscribeToStatus(onChange);
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(onChange);
|
||||
return () => {
|
||||
unsubBackend();
|
||||
unsubServer();
|
||||
};
|
||||
};
|
||||
const getBackendOnline = () => tauriBackendService.isOnline;
|
||||
const getOffline = () => isSelfHostedOffline();
|
||||
|
||||
/**
|
||||
* Desktop-specific endpoint checker that hits the backend directly via axios.
|
||||
* When the desktop backend is reachable: either the bundled backend is healthy,
|
||||
* or the self-hosted server is offline but the local one answers. A query only
|
||||
* runs once this is true, and a change re-runs it — which is how a reconnect
|
||||
* swaps the offline local-check answer for the live remote one.
|
||||
*/
|
||||
function useBackendReadiness() {
|
||||
const backendOnline = useSyncExternalStore(
|
||||
subscribeReadiness,
|
||||
getBackendOnline,
|
||||
);
|
||||
const offline = useSyncExternalStore(subscribeReadiness, getOffline);
|
||||
return { ready: backendOnline || offline, backendOnline, offline };
|
||||
}
|
||||
|
||||
const retryWhileStarting = (_count: number, error: unknown) =>
|
||||
isBackendNotReadyError(error);
|
||||
|
||||
/** Desktop override: hits the backend directly, optimistic while it boots. */
|
||||
export function useEndpointEnabled(endpoint: string): {
|
||||
enabled: boolean | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
// DESKTOP: Start optimistically as enabled (most desktop users are in SaaS mode)
|
||||
// This prevents UI from being disabled while backend starts or checks are in progress
|
||||
const [enabled, setEnabled] = useState<boolean | null>(true);
|
||||
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 queryClient = useQueryClient();
|
||||
const { ready, backendOnline, offline } = useBackendReadiness();
|
||||
const queryKey = qk.endpointEnabled(endpoint);
|
||||
|
||||
const clearRetryTimeout = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => resolveEndpointEnabled(endpoint),
|
||||
enabled: Boolean(endpoint) && ready,
|
||||
staleTime: CONFIG_STALE_TIME,
|
||||
retry: retryWhileStarting,
|
||||
retryDelay: RETRY_DELAY_MS,
|
||||
});
|
||||
|
||||
// Re-run only when readiness or the endpoint changes; queryClient/queryKey
|
||||
// are stable and deliberately excluded.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearRetryTimeout();
|
||||
};
|
||||
}, [clearRetryTimeout]);
|
||||
|
||||
const fetchEndpointStatus = useCallback(async () => {
|
||||
clearRetryTimeout();
|
||||
|
||||
if (!endpoint) {
|
||||
if (!isMountedRef.current) return;
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
|
||||
const locallyEnabled = response.data;
|
||||
|
||||
if (!locallyEnabled) {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
|
||||
// Even if not supported locally, they will route to SaaS backend
|
||||
if (mode === "saas") {
|
||||
console.debug(
|
||||
`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
|
||||
);
|
||||
setEnabled(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(locallyEnabled);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchEndpointStatus();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === "saas") {
|
||||
console.debug(
|
||||
`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
|
||||
);
|
||||
setEnabled(true); // Available via SaaS
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(message);
|
||||
setEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
fetchEndpointStatus();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoint, fetchEndpointStatus]);
|
||||
if (ready) void queryClient.invalidateQueries({ queryKey });
|
||||
}, [backendOnline, offline, endpoint]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEndpointStatus,
|
||||
enabled: endpoint ? (data ?? true) : null,
|
||||
// Optimistic by design: the desktop endpoint check never blocks the UI.
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,247 +101,48 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [endpointDetails, setEndpointDetails] = useState<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { ready, backendOnline, offline } = useBackendReadiness();
|
||||
const wanted = endpoints ?? [];
|
||||
const key = wanted.join(",");
|
||||
const queryKey = qk.endpointsAvailability();
|
||||
|
||||
const clearRetryTimeout = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const { data, isPending, refetch } = useQuery({
|
||||
queryKey,
|
||||
// key drives the legacy fallback param and the offline per-endpoint checks;
|
||||
// the shared cache entry is still the whole map, projected per consumer.
|
||||
queryFn: () => resolveEndpointsAvailability(key ? key.split(",") : []),
|
||||
enabled: wanted.length > 0 && ready,
|
||||
staleTime: CONFIG_STALE_TIME,
|
||||
retry: retryWhileStarting,
|
||||
retryDelay: RETRY_DELAY_MS,
|
||||
});
|
||||
|
||||
// A reconnect (readiness flips) forces the swap from offline to remote data.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearRetryTimeout();
|
||||
};
|
||||
}, [clearRetryTimeout]);
|
||||
if (ready) void queryClient.invalidateQueries({ queryKey });
|
||||
}, [backendOnline, offline]);
|
||||
|
||||
const fetchAllEndpointStatuses = useCallback(async () => {
|
||||
clearRetryTimeout();
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
const projected = useMemo(() => {
|
||||
const status: Record<string, boolean> = {};
|
||||
const details: Record<string, EndpointAvailabilityDetails> = {};
|
||||
if (!data) return { status, details };
|
||||
for (const endpoint of key ? key.split(",") : []) {
|
||||
const detail = data[endpoint] ?? OPTIMISTIC;
|
||||
status[endpoint] = detail.enabled;
|
||||
details[endpoint] = detail;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Try new API first (no params — new servers return all endpoints).
|
||||
// Fall back to the old ?endpoints= form for servers that predate the
|
||||
// "large query reduction" change and still require the parameter.
|
||||
let response: Awaited<
|
||||
ReturnType<
|
||||
typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>
|
||||
>
|
||||
>;
|
||||
try {
|
||||
response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
} catch (innerErr) {
|
||||
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
|
||||
// Old server — requires explicit endpoints query param
|
||||
console.debug(
|
||||
"[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format",
|
||||
);
|
||||
const endpointsParam = endpoints.join(",");
|
||||
response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
} else {
|
||||
throw innerErr;
|
||||
}
|
||||
}
|
||||
|
||||
const details = Object.entries(response.data).reduce(
|
||||
(acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, EndpointAvailabilityDetails>,
|
||||
);
|
||||
|
||||
const statusMap = Object.keys(details).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
|
||||
// They will route to SaaS backend
|
||||
if (mode === "saas") {
|
||||
const disabledEndpoints = Object.keys(details).filter(
|
||||
(key) => !details[key].enabled,
|
||||
);
|
||||
|
||||
for (const endpoint of disabledEndpoints) {
|
||||
console.debug(
|
||||
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
|
||||
);
|
||||
statusMap[endpoint] = true; // Mark as enabled via SaaS
|
||||
details[endpoint] = { enabled: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
setEndpointDetails((prev) => ({ ...prev, ...details }));
|
||||
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
if (isBackendStarting) {
|
||||
setError(t("backendHealth.starting", "Backend starting up..."));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchAllEndpointStatuses();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
setError(message);
|
||||
const fallbackStatus = endpoints.reduce<{
|
||||
status: Record<string, boolean>;
|
||||
details: Record<string, EndpointAvailabilityDetails>;
|
||||
}>(
|
||||
(acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = {
|
||||
enabled: false,
|
||||
reason: "UNKNOWN",
|
||||
};
|
||||
acc.status[endpointName] = false;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {},
|
||||
details: {},
|
||||
},
|
||||
);
|
||||
|
||||
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === "saas") {
|
||||
for (const endpoint of endpoints) {
|
||||
console.debug(
|
||||
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
|
||||
);
|
||||
fallbackStatus.status[endpoint] = true;
|
||||
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoints, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelfHostedOffline()) {
|
||||
fetchAllEndpointStatuses();
|
||||
const unsubServer = selfHostedServerMonitor.subscribe(() => {
|
||||
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
return unsubServer;
|
||||
}
|
||||
|
||||
if (tauriBackendService.isOnline) {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === "healthy") {
|
||||
fetchAllEndpointStatuses();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [endpoints, fetchAllEndpointStatuses]);
|
||||
return { status, details };
|
||||
}, [data, key]);
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAllEndpointStatuses,
|
||||
endpointStatus: projected.status,
|
||||
endpointDetails: projected.details,
|
||||
loading: wanted.length > 0 && isPending,
|
||||
error: null,
|
||||
refetch: useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user