From 3985f79dda0590f5acbe2db3bd0672d7cd6af80c Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 2 Sep 2026 15:00:49 +0100 Subject: [PATCH] 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. --- .../src/desktop/api/endpointAvailability.ts | 174 ++++++ .../desktop/hooks/useEndpointConfig.test.tsx | 27 +- .../src/desktop/hooks/useEndpointConfig.ts | 512 ++++-------------- 3 files changed, 298 insertions(+), 415 deletions(-) create mode 100644 frontend/editor/src/desktop/api/endpointAvailability.ts diff --git a/frontend/editor/src/desktop/api/endpointAvailability.ts b/frontend/editor/src/desktop/api/endpointAvailability.ts new file mode 100644 index 0000000000..62ba2401f6 --- /dev/null +++ b/frontend/editor/src/desktop/api/endpointAvailability.ts @@ -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 { + try { + const response = await apiClient.get( + "/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 { + try { + const response = await apiClient.get( + "/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( + `/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 { + 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 { + 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 { + 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( + `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, + { suppressErrorToast: true }, + ); + return response.data || saas; + } catch (error) { + if (isBackendNotReadyError(error)) throw error; + return saas; + } +} diff --git a/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx b/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx index 931bb454c2..049166455d 100644 --- a/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx +++ b/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx @@ -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. diff --git a/frontend/editor/src/desktop/hooks/useEndpointConfig.ts b/frontend/editor/src/desktop/hooks/useEndpointConfig.ts index 490a793df0..1a12afa8a8 100644 --- a/frontend/editor/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/desktop/hooks/useEndpointConfig.ts @@ -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 { - try { - const response = await apiClient.get( - "/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; } { - 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(true); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const isMountedRef = useRef(true); - const retryTimeoutRef = useRef | 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( - `/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; } { - const { t } = useTranslation(); - const [endpointStatus, setEndpointStatus] = useState>( - {}, - ); - const [endpointDetails, setEndpointDetails] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const isMountedRef = useRef(true); - const retryTimeoutRef = useRef | 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 = {}; + const details: Record = {}; + 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 = {}; - const details: Record = {}; - 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> - > - >; - try { - response = await apiClient.get< - Record - >(`/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 - >( - `/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, - ); - - const statusMap = Object.keys(details).reduce( - (acc, key) => { - acc[key] = details[key].enabled; - return acc; - }, - {} as Record, - ); - - 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; - details: Record; - }>( - (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]), }; }