* fix: only show connection lost when the server check fails too

* fix: load the macOS tray icon from the right unpacked folder (#1728)

* fix: hide update and review buttons on disabled plugins (#1745)

* fix: save the database without blocking the server, bump to 26.11.0

* fix: try the key before the password for hosts with both (Plugin-SSH-Terminal#50)

* fix: add macOS Window menu item to reopen main window

* feat: upload Mac App Store screenshots on submit

* chore: drop internal note from changelog

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.11.0
This commit is contained in:
Luke Gustafson authored and GitHub committed 2026-11-01 17:16:04 -06:00
1 parent c759a147c6
commit 1d4eac3e20
24 files changed
+337 -56

No files matched your search

+34 -6
View File
@@ -31,6 +31,8 @@ class DatabaseHealthMonitor {
private static instance: DatabaseHealthMonitor;
private listeners: Map<string, EventListener[]> = new Map();
private degradedActive: boolean = false;
private probe: (() => Promise<boolean>) | null = null;
private probing: Promise<void> | 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<boolean>) | null) {
this.probe = probe;
}
private markDegraded(errorMessage: string, errorCode: string | undefined) {
if (!this.degradedActive) {
this.degradedActive = true;
this.emit("database-connection-degraded", {
+12
View File
@@ -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");
+6 -4
View File
@@ -155,10 +155,12 @@ export function mergePlugins(
latestBeta: entry?.latestBeta ?? null,
channel: summary.channel ?? entry?.channel ?? "stable",
isBeta: isPrerelease(summary.version),
// The server keeps its own copy up to date.
updateAvailable: summary.runsOnServer
? false
: (entry?.updateAvailable ?? false),
// The server keeps its own copy up to date, and a plugin that is off
// should not ask for attention.
updateAvailable:
summary.runsOnServer || !summary.enabled
? false
: (entry?.updateAvailable ?? false),
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
capabilities: summary.capabilities ?? [],
@@ -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<typeof watch>;
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<boolean>((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();
});
});
@@ -142,6 +142,14 @@ describe("mergePlugins", () => {
expect(row).toMatchObject({ status: "server", updateAvailable: false });
});
it("offers no update or review for a disabled plugin", () => {
const [row] = mergePlugins(
[summary({ enabled: false, state: "disabled" })],
[entry({ addedCapabilities: ["host:read"] })],
);
expect(row.updateAvailable).toBe(false);
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);