import axios, { type AxiosRequestConfig } from "axios"; import { handleApiError, getRemoteCoreApi, isElectron, sshHostApi, } from "@/main-axios"; import type { ServerStatus } from "@/main-axios"; import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; import { resolveConnectionOrigin } from "@/lib/connection-origin"; import { getLinkedSession } from "@/lib/linked-server"; import type { SSHHost } from "@/types/index"; // Core's host status checks (/host/status). On a linked desktop, hosts that // connect through the server take their status from it, matched by sync id. // HOST STATUS // ============================================================================ /** * Progressive retry schedule for the background /status poll. * * Each entry describes one attempt's per-request timeout and the pause to * observe before the next attempt. The pause on the last entry is `null`: * after that final failure we surface the network error, which flows * through the response interceptor + dbHealthMonitor (which decides * between the degraded toast and the full-outage overlay based on whether * any WebSocket is still alive). * * Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail. * Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext * poll cadence, so the next tick acts as the next retry without overlap. */ const STATUS_RETRY_SCHEDULE: ReadonlyArray<{ timeoutMs: number; pauseAfterMs: number | null; }> = [ { timeoutMs: 2000, pauseAfterMs: 3000 }, { timeoutMs: 5000, pauseAfterMs: 5000 }, { timeoutMs: 8000, pauseAfterMs: null }, ]; function isTransientStatusError(error: unknown): boolean { if (!axios.isAxiosError(error)) return false; if (error.response) { // Definitive server response (even 5xx) is not something more retries // will fix in a useful timeframe; bail out and report it normally. return false; } const code = error.code; if (!code) { // No code + no response means classic network error (offline / DNS / TCP) return true; } return ( code === "ECONNABORTED" || code === "ETIMEDOUT" || code === "ERR_NETWORK" || code === "ECONNREFUSED" || code === "ECONNRESET" ); } /** Throws when the statuses could not be read, so callers keep what they had. */ export async function getAllServerStatuses(): Promise< Record > { return getCachedServerStatuses(async () => { let lastError: unknown = null; let localStatuses: Record = {}; let localHostIds: number[] | null = null; let remoteHostsBySyncId: Map | null = null; if (isElectron()) { // A host-list failure throws here: sending no ids would stop every check. const response = await sshHostApi.get("/db/host"); const defaultOrigin = await resolveConnectionOrigin({ connectionOrigin: null, }); const hosts = response.data || []; localHostIds = hosts .filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local") .map((host) => host.id); remoteHostsBySyncId = new Map( hosts .filter( (host) => (host.connectionOrigin ?? defaultOrigin) === "remote" && !!host.syncId, ) .map((host) => [host.syncId as string, host.id]), ); } for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1; try { const response = await sshHostApi.get("/status", { timeout: timeoutMs, ...(localHostIds === null ? {} : { params: { hostIds: localHostIds.join(",") } }), // Silence per-attempt interceptor logging & health-monitor side // effects on all attempts except the final one, so background // blips don't look like real outages. __silentRetry: !isFinalAttempt, } as AxiosRequestConfig & { __silentRetry?: boolean }); localStatuses = response.data || {}; lastError = null; break; } catch (error) { lastError = error; if (!isTransientStatusError(error)) { break; } if (pauseAfterMs === null) { break; } await new Promise((resolve) => setTimeout(resolve, pauseAfterMs)); } } if (lastError) handleApiError(lastError, "fetch server statuses"); if (remoteHostsBySyncId?.size && (await getLinkedSession())) { try { const remoteResult = await getRemoteCoreApi().get( "/sync/v2/host-status", { timeout: 8000, __silentRetry: true, } as AxiosRequestConfig & { __silentRetry?: boolean }, ); const merged = { ...localStatuses }; for (const [syncId, status] of Object.entries( (remoteResult.data || {}) as Record, )) { const localId = remoteHostsBySyncId.get(syncId); if (localId !== undefined) merged[localId] = status; } return merged; } catch { // The server is unreachable this tick; local statuses still stand. } } return localStatuses; }); } export async function getServerStatusById(id: number): Promise { try { const response = await sshHostApi.get(`/status/${id}`); return response.data; } catch (error) { handleApiError(error, "fetch server status"); throw error; } } export async function refreshServerPolling(): Promise { try { await sshHostApi.post("/status/refresh"); } catch (error) { console.warn("Failed to refresh status checks:", error); } } export async function getStatusCheckSettings(): Promise<{ statusCheckInterval: number; }> { try { const response = await sshHostApi.get("/status/settings"); return response.data; } catch (error) { handleApiError(error, "fetch status check settings"); } } export async function updateStatusCheckSettings(settings: { statusCheckInterval: number; }): Promise { try { await sshHostApi.put("/status/settings", settings); } catch (error) { handleApiError(error, "update status check settings"); } } // ============================================================================