Files
Termix/src/ui/api/host-status-api.ts
T

193 lines
6.3 KiB
TypeScript
Raw Normal View History

2026-10-01 16:20:16 -05:00
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"
);
}
2026-10-04 15:11:41 -05:00
/** Throws when the statuses could not be read, so callers keep what they had. */
2026-10-01 16:20:16 -05:00
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
let localHostIds: number[] | null = null;
let remoteHostsBySyncId: Map<string, number> | null = null;
if (isElectron()) {
2026-10-04 15:11:41 -05:00
// A host-list failure throws here: sending no ids would stop every check.
const response = await sshHostApi.get<SSHHost[]>("/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
2026-10-01 16:20:16 -05:00
.filter(
2026-10-04 15:11:41 -05:00
(host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
2026-10-01 16:20:16 -05:00
)
2026-10-04 15:11:41 -05:00
.map((host) => [host.syncId as string, host.id]),
);
2026-10-01 16:20:16 -05:00
}
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));
}
}
2026-10-04 15:11:41 -05:00
if (lastError) handleApiError(lastError, "fetch server statuses");
2026-10-01 16:20:16 -05:00
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<string, ServerStatus>,
)) {
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<ServerStatus> {
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<void> {
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<void> {
try {
await sshHostApi.put("/status/settings", settings);
} catch (error) {
handleApiError(error, "update status check settings");
}
}
// ============================================================================