diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b26a520..8a1282731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 26.10.7 + +### Fixed + +- One slow request, like a plugin waiting on an SSH host, no longer shows Server connection lost. The app now checks the server first +- Canceled requests no longer show Server connection lost + ## 26.10.6 ### Added diff --git a/package-lock.json b/package-lock.json index 957ec17d3..47204c90a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.6", + "version": "26.10.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.6", + "version": "26.10.7", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index fb1a9a06d..54017e717 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.6", + "version": "26.10.7", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/ui/lib/db-health-monitor.ts b/src/ui/lib/db-health-monitor.ts index 1c9a96647..bcfbe2416 100644 --- a/src/ui/lib/db-health-monitor.ts +++ b/src/ui/lib/db-health-monitor.ts @@ -31,6 +31,8 @@ class DatabaseHealthMonitor { private static instance: DatabaseHealthMonitor; private listeners: Map = new Map(); private degradedActive: boolean = false; + private probe: (() => Promise) | null = null; + private probing: Promise | null = null; private constructor() {} @@ -83,22 +85,48 @@ class DatabaseHealthMonitor { errorCode === "DATABASE_ERROR" || errorCode === "DB_CONNECTION_FAILED"; + // A cancel is on purpose and says nothing about the backend. + if (errorCode === "ERR_CANCELED" || lowerMessage === "canceled") return; + const isBackendUnreachable = errorCode === "ERR_NETWORK" || errorCode === "ECONNREFUSED" || - errorCode === "ECONNABORTED" || errorCode === "ECONNRESET" || - errorCode === "ETIMEDOUT" || - errorCode === "ERR_CANCELED" || (lowerMessage.includes("network error") && - errorLike.response === undefined) || + errorLike.response === undefined); + + // One slow request (like a plugin waiting on an SSH host) is not a lost + // backend, so a timeout only counts when a quick check fails too. + const isTimeout = + errorCode === "ECONNABORTED" || + errorCode === "ETIMEDOUT" || lowerMessage.includes("request aborted") || lowerMessage.includes("timeout"); - if (!(isDatabaseError || isBackendUnreachable)) { - return; + if (isDatabaseError || isBackendUnreachable) { + this.markDegraded(errorMessage, errorCode); + } else if (isTimeout) { + if (!this.probe) { + this.markDegraded(errorMessage, errorCode); + return; + } + this.probing ??= this.probe() + .catch(() => false) + .then((reachable) => { + if (!reachable) this.markDegraded(errorMessage, errorCode); + }) + .finally(() => { + this.probing = null; + }); } + } + /** Sets the quick check used after a request times out. */ + setReachabilityProbe(probe: (() => Promise) | null) { + this.probe = probe; + } + + private markDegraded(errorMessage: string, errorCode: string | undefined) { if (!this.degradedActive) { this.degradedActive = true; this.emit("database-connection-degraded", { diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f923be108..96fb3faa4 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -676,6 +676,18 @@ function initializeApiInstances() { // Authentication API (port 30001) authApi = createApiInstance(getApiUrl("", 30001), "AUTH"); + const healthApi = authApi; + dbHealthMonitor.setReachabilityProbe(() => + healthApi + .get("/health", { + timeout: 5000, + __silentRetry: true, + } as AxiosRequestConfigExtended) + .then( + () => true, + () => false, + ), + ); // RBAC API (port 30001) rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); diff --git a/src/ui/tests/lib/db-health-monitor.test.ts b/src/ui/tests/lib/db-health-monitor.test.ts new file mode 100644 index 000000000..d8bd6fe4c --- /dev/null +++ b/src/ui/tests/lib/db-health-monitor.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dbHealthMonitor } from "@/lib/db-health-monitor"; + +function watch() { + const degraded = vi.fn<(...args: unknown[]) => void>(); + dbHealthMonitor.on("database-connection-degraded", degraded); + return degraded; +} + +describe("dbHealthMonitor", () => { + let degraded: ReturnType; + + afterEach(() => { + dbHealthMonitor.off("database-connection-degraded", degraded); + dbHealthMonitor.setReachabilityProbe(null); + dbHealthMonitor.reset(); + }); + + it("flags a network error at once", () => { + degraded = watch(); + dbHealthMonitor.reportDatabaseError({ code: "ERR_NETWORK" }); + expect(degraded).toHaveBeenCalledTimes(1); + }); + + it("ignores canceled requests", () => { + degraded = watch(); + dbHealthMonitor.reportDatabaseError({ + code: "ERR_CANCELED", + message: "canceled", + }); + expect(degraded).not.toHaveBeenCalled(); + }); + + it("does not flag a timeout when the backend answers", async () => { + degraded = watch(); + const probe = vi.fn(async () => true); + dbHealthMonitor.setReachabilityProbe(probe); + dbHealthMonitor.reportDatabaseError({ + code: "ECONNABORTED", + message: "timeout of 30000ms exceeded", + }); + await vi.waitFor(() => expect(probe).toHaveBeenCalled()); + await Promise.resolve(); + expect(degraded).not.toHaveBeenCalled(); + }); + + it("flags a timeout when the check fails too", async () => { + degraded = watch(); + dbHealthMonitor.setReachabilityProbe(async () => false); + dbHealthMonitor.reportDatabaseError({ + code: "ECONNABORTED", + message: "timeout of 30000ms exceeded", + }); + await vi.waitFor(() => expect(degraded).toHaveBeenCalledTimes(1)); + }); + + it("runs one check for timeouts at the same time", async () => { + degraded = watch(); + let answer: (ok: boolean) => void = () => {}; + const probe = vi.fn( + () => new Promise((resolve) => (answer = resolve)), + ); + dbHealthMonitor.setReachabilityProbe(probe); + dbHealthMonitor.reportDatabaseError({ code: "ECONNABORTED" }); + dbHealthMonitor.reportDatabaseError({ code: "ECONNABORTED" }); + answer(true); + await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1)); + expect(degraded).not.toHaveBeenCalled(); + }); +});