* 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

+3 -1
View File
@@ -1316,7 +1316,9 @@ jobs:
--app_identifier "com.karmaa.termix" \
--skip_metadata false \
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
--skip_screenshots true \
--skip_screenshots false \
--screenshots_path packaging/appstore/screenshots \
--overwrite_screenshots true \
--skip_app_version_update false \
--submit_for_review true \
--automatic_release true \
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 26.11.0
### Fixed
- Saving the database no longer holds up the whole server while it encrypts and writes. A big database is also saved less often by background writes, which fixes slow requests and dropped connections
- The macOS menu bar icon shows the logo instead of an empty shape on multi-architecture builds
- Disabled plugins no longer show update or review buttons, and no longer count as pending updates
- 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
- The macOS Window menu has an item to reopen the main window after it is closed
- Hosts with both an SSH key and a password can log in to servers set to AuthenticationMethods publickey,password
## 26.10.6
### Added
+22 -22
View File
@@ -10,8 +10,8 @@
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug",
"sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2",
"tier": "full"
},
{
@@ -42,8 +42,8 @@
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.0/classic-layout-1.1.0.tmxplug",
"sha256": "06a94fdbc2eda2f7cdeb4a6bbdc6adb289d524abfe836d3a32ac9b38ad5f65b9",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug",
"sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47",
"tier": "minimal",
"defaultLook": true
},
@@ -79,8 +79,8 @@
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.2/docker-1.2.2.tmxplug",
"sha256": "2e6ff0a68a70d83267c250547e6a4e4f6282d557b608500309136aca76881abf",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug",
"sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14",
"tier": "standard",
"onboarding": {
"recommended": true,
@@ -90,8 +90,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.2/file-manager-1.1.2.tmxplug",
"sha256": "6769e58f23722c06b6e4c187c7ef5ba03f28bbdc19e859104b7a5ead8690c154",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug",
"sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca",
"tier": "standard",
"onboarding": {
"recommended": true,
@@ -115,8 +115,8 @@
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.0/host-metrics-1.3.0.tmxplug",
"sha256": "b38352e4adebf4079918cd7e9e9badfb0a4a9811cc53940852ca71aef860dc26",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug",
"sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5",
"tier": "standard",
"onboarding": {
"recommended": true,
@@ -182,15 +182,15 @@
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.0/proxmox-1.2.0.tmxplug",
"sha256": "154c9bc1535bb1d05ccad3ab2cb6d848a28ddbfeb6dc589c5683cd7e81ccd3c3",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug",
"sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74",
"tier": "full"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.0/remote-desktop-1.3.0.tmxplug",
"sha256": "69177d2a642d549fbd4769833437fdb96065251a4965990b881d1dd583b2832a",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug",
"sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9",
"tier": "standard",
"onboarding": {
"recommended": true,
@@ -214,8 +214,8 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.0/secret-sources-1.1.0.tmxplug",
"sha256": "3ba67666a52a38245284bb0294fd7af52a5613921454e66d3b4972727d7ed5c0",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug",
"sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee",
"tier": "full"
},
{
@@ -239,8 +239,8 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug",
"sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e",
"tier": "full"
},
{
@@ -271,8 +271,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.2.0/ssh-terminal-1.2.0.tmxplug",
"sha256": "629b522ae6ff77a8e033b89bb2efa675ec60c147c378012548feec1a005f9533",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug",
"sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e",
"tier": "standard",
"onboarding": {
"recommended": true,
@@ -282,8 +282,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug",
"sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c",
"tier": "standard",
"onboarding": {
"recommended": false,
+14 -7
View File
@@ -1235,13 +1235,7 @@ function createTray() {
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path.join(appRoot, "public")
: path.join(
appRoot.replace(
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
),
"public",
);
: path.join(getUnpackedAppRoot(appRoot), "public");
let trayIcon;
if (process.platform === "darwin") {
@@ -2027,6 +2021,19 @@ function createMenu() {
{ type: "separator" },
{ role: "front" },
{ type: "separator" },
{
label: app.name,
accelerator: "CmdOrCtrl+0",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
} else {
createWindow();
}
},
},
{ role: "window" },
],
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.6",
"version": "26.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.6",
"version": "26.11.0",
"hasInstallScript": true,
"workspaces": [
"packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.6",
"version": "26.11.0",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+6 -4
View File
@@ -1812,6 +1812,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
if (!memoryDatabase) return;
try {
// Before the copy, so a write during the slow part below stays dirty.
DatabaseSaveTrigger.markClean();
const buffer = memoryDatabase.serialize();
if (!fs.existsSync(dataDir)) {
@@ -1832,11 +1834,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
encryptedDbPath,
);
} else {
fs.writeFileSync(dbPath, buffer);
await fs.promises.writeFile(dbPath, buffer);
}
DatabaseSaveTrigger.markClean();
} catch (error) {
DatabaseSaveTrigger.markDirty();
databaseLogger.error("Failed to save in-memory database", error, {
operation: "memory_db_save_failed",
enableFileEncryption,
@@ -1855,7 +1856,8 @@ async function handlePostInitFileEncryption() {
setInterval(() => {
if (DatabaseSaveTrigger.isDirty) {
saveMemoryDatabaseToFile();
// Through the trigger so it waits for any save already running.
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
}
}, 5 * 60 * 1000);
}
@@ -99,7 +99,12 @@ async function prepareKey(
if (passphrase) config.passphrase = passphrase;
// Lets a server that wants key then password (or password only) still work.
if (host.password) config.password = host.password;
// ssh2 tries password first by default, so it has none left to answer with
// once the key was accepted as the first step.
if (host.password) {
config.password = host.password;
config.authHandler = ["publickey", "password", "keyboard-interactive"];
}
const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) {
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import ssh2 from "ssh2";
const { Client, Server, utils } = ssh2;
// The order prepareKey sets when a host has both a key and a password.
const AUTH_ORDER = ["publickey", "password", "keyboard-interactive"];
const keys = utils.generateKeyPairSync("ed25519");
let server: InstanceType<typeof Server> | null = null;
afterEach(() => server?.close());
/** A server with AuthenticationMethods publickey,password. */
function start(): Promise<number> {
server = new Server({ hostKeys: [keys.private] }, (client) => {
let keyDone = false;
client.on("authentication", (ctx) => {
if (ctx.method === "publickey") {
if (ctx.signature) {
keyDone = true;
ctx.reject(["password"], true);
} else {
ctx.accept();
}
} else if (ctx.method === "password" && keyDone) {
ctx.accept();
} else {
ctx.reject(keyDone ? ["password"] : ["publickey"]);
}
});
client.on("ready", () => client.end());
client.on("error", () => {});
});
return new Promise((resolve) => {
server!.listen(0, "127.0.0.1", () =>
resolve((server!.address() as { port: number }).port),
);
});
}
function connect(port: number, authHandler?: string[]) {
return new Promise<string>((resolve) => {
const client = new Client();
client.on("ready", () => {
client.end();
resolve("ready");
});
client.on("error", (error) => resolve(error.message));
client.connect({
host: "127.0.0.1",
port,
username: "u",
privateKey: keys.private,
password: "pw",
...(authHandler ? { authHandler } : {}),
} as never);
});
}
describe("a server that wants a key, then a password", () => {
it("logs in with the key first order", async () => {
expect(await connect(await start(), AUTH_ORDER)).toBe("ready");
});
it("fails with ssh2's default order, which tries the password first", async () => {
expect(await connect(await start())).toMatch(/authentication/i);
});
});
@@ -205,6 +205,11 @@ describe("buildConnectConfig per auth type", () => {
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
expect(config.passphrase).toBe("secret");
expect(config.password).toBe("fallback");
expect(config.authHandler).toEqual([
"publickey",
"password",
"keyboard-interactive",
]);
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
});
@@ -1,7 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
DatabaseSaveTrigger,
lazySaveGapMs,
} from "../../utils/database-save-trigger.js";
describe("lazySaveGapMs", () => {
it("waits longer after slow saves, within limits", () => {
expect(lazySaveGapMs(0)).toBe(30_000);
expect(lazySaveGapMs(2_000)).toBe(60_000);
expect(lazySaveGapMs(60_000)).toBe(300_000);
});
});
describe("DatabaseSaveTrigger", () => {
it("stays dirty when a write lands while a save is running", async () => {
let finish: () => void = () => {};
const save = vi.fn(
() => new Promise<void>((resolve) => (finish = resolve)),
);
DatabaseSaveTrigger.initialize(save);
DatabaseSaveTrigger.markDirty();
const running = DatabaseSaveTrigger.forceSave("first");
await new Promise((resolve) => setImmediate(resolve));
DatabaseSaveTrigger.markDirty();
finish();
await running;
expect(DatabaseSaveTrigger.isDirty).toBe(true);
});
afterEach(() => {
vi.useRealTimers();
DatabaseSaveTrigger.cleanup();
+20 -4
View File
@@ -16,6 +16,8 @@ interface EncryptedFileMetadata {
dataSize?: number;
}
const ENCRYPT_CHUNK_BYTES = 4 * 1024 * 1024;
class DatabaseFileEncryption {
private static readonly VERSION = "v2";
private static readonly ALGORITHM = "aes-256-gcm";
@@ -27,7 +29,7 @@ class DatabaseFileEncryption {
buffer: Buffer,
targetPath: string,
): Promise<string> {
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`;
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
try {
@@ -38,7 +40,21 @@ class DatabaseFileEncryption {
key,
iv,
) as crypto.CipherGCM;
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
// This runs on every save, so a big database must not hold up the event
// loop for the whole encryption.
const parts: Buffer[] = [];
for (
let offset = 0;
offset < buffer.length;
offset += ENCRYPT_CHUNK_BYTES
) {
parts.push(
cipher.update(buffer.subarray(offset, offset + ENCRYPT_CHUNK_BYTES)),
);
await new Promise<void>((resolve) => setImmediate(resolve));
}
parts.push(cipher.final());
const encrypted = Buffer.concat(parts);
const tag = cipher.getAuthTag();
const metadata: EncryptedFileMetadata = {
@@ -62,8 +78,8 @@ class DatabaseFileEncryption {
encrypted,
]);
fs.writeFileSync(tmpPath, finalBuffer);
fs.renameSync(tmpPath, targetPath);
await fs.promises.writeFile(tmpPath, finalBuffer);
await fs.promises.rename(tmpPath, targetPath);
try {
if (fs.existsSync(metadataPath)) {
+21 -3
View File
@@ -6,6 +6,17 @@ const DEBOUNCE_MS = 2000;
// Every save rewrites the whole database file, so background writes (metrics
// samples, audit rows) may only cause one save per this window.
const MIN_LAZY_SAVE_GAP_MS = 30_000;
const MAX_LAZY_SAVE_GAP_MS = 5 * 60_000;
// A database that takes long to save is saved less often by background writes.
const LAZY_GAP_PER_SAVE_MS = 30;
/** How long background writes wait after a save that took saveMs. */
export function lazySaveGapMs(saveMs: number): number {
return Math.min(
MAX_LAZY_SAVE_GAP_MS,
Math.max(MIN_LAZY_SAVE_GAP_MS, saveMs * LAZY_GAP_PER_SAVE_MS),
);
}
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
@@ -14,6 +25,7 @@ export class DatabaseSaveTrigger {
private static activeSave: Promise<void> | null = null;
private static saveTimeout: NodeJS.Timeout | null = null;
private static lastSaveAt = 0;
private static lastSaveMs = 0;
private static _dirty = false;
private static batch = new AsyncLocalStorage<{
open: boolean;
@@ -56,7 +68,7 @@ export class DatabaseSaveTrigger {
const delay = Math.max(
DEBOUNCE_MS,
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(),
this.lastSaveAt + lazySaveGapMs(this.lastSaveMs) - Date.now(),
);
this.saveTimeout = setTimeout(async () => {
@@ -64,7 +76,6 @@ export class DatabaseSaveTrigger {
try {
await this.runSave();
this._dirty = false;
} catch (error) {
databaseLogger.error("Database save failed", error, {
operation: "db_save_trigger_failed",
@@ -122,7 +133,6 @@ export class DatabaseSaveTrigger {
try {
await this.runSave();
this._dirty = false;
} catch (error) {
databaseLogger.error("Database force save failed", error, {
operation: "db_save_trigger_force_failed",
@@ -152,10 +162,17 @@ export class DatabaseSaveTrigger {
this.activeSave = save;
this.pendingSave = true;
this.lastSaveAt = Date.now();
// Cleared up front: saves are slow, and a write landing during one must
// stay dirty for the next.
this._dirty = false;
try {
await save;
} catch (error) {
this._dirty = true;
throw error;
} finally {
this.lastSaveMs = Date.now() - this.lastSaveAt;
if (this.activeSave === save) {
this.activeSave = null;
this.pendingSave = false;
@@ -181,6 +198,7 @@ export class DatabaseSaveTrigger {
this.saveTimeout = null;
}
this.lastSaveAt = 0;
this.lastSaveMs = 0;
this.pendingSave = false;
this.activeSave = null;
+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);