diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index d2fcaa1f3..ae49c638f 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -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 \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b26a520..247d6a88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index 7e87a9625..ff7336f42 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -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, diff --git a/electron/main.cjs b/electron/main.cjs index d6dfa77c4..a179f0aab 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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" }, ], }, diff --git a/package-lock.json b/package-lock.json index 957ec17d3..78e0ee0a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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/*" diff --git a/package.json b/package.json index fb1a9a06d..e313cca48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packaging/appstore/screenshots/en-US/01-dashboard.png b/packaging/appstore/screenshots/en-US/01-dashboard.png new file mode 100644 index 000000000..82266534e Binary files /dev/null and b/packaging/appstore/screenshots/en-US/01-dashboard.png differ diff --git a/packaging/appstore/screenshots/en-US/02-terminal.png b/packaging/appstore/screenshots/en-US/02-terminal.png new file mode 100644 index 000000000..b0300d4eb Binary files /dev/null and b/packaging/appstore/screenshots/en-US/02-terminal.png differ diff --git a/packaging/appstore/screenshots/en-US/03-split.png b/packaging/appstore/screenshots/en-US/03-split.png new file mode 100644 index 000000000..aeba420e0 Binary files /dev/null and b/packaging/appstore/screenshots/en-US/03-split.png differ diff --git a/packaging/appstore/screenshots/en-US/04-hosts.png b/packaging/appstore/screenshots/en-US/04-hosts.png new file mode 100644 index 000000000..3ef2a5d21 Binary files /dev/null and b/packaging/appstore/screenshots/en-US/04-hosts.png differ diff --git a/packaging/appstore/screenshots/en-US/05-manage.png b/packaging/appstore/screenshots/en-US/05-manage.png new file mode 100644 index 000000000..fa17ec689 Binary files /dev/null and b/packaging/appstore/screenshots/en-US/05-manage.png differ diff --git a/packaging/appstore/screenshots/en-US/06-settings.png b/packaging/appstore/screenshots/en-US/06-settings.png new file mode 100644 index 000000000..423624ba9 Binary files /dev/null and b/packaging/appstore/screenshots/en-US/06-settings.png differ diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c974c79b2..c70cef2ac 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1812,6 +1812,8 @@ async function saveMemoryDatabaseToFile(): Promise { 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 { 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); } diff --git a/src/backend/hosts/connect/builtin-providers.ts b/src/backend/hosts/connect/builtin-providers.ts index 7bd87f82c..76151fbb5 100644 --- a/src/backend/hosts/connect/builtin-providers.ts +++ b/src/backend/hosts/connect/builtin-providers.ts @@ -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()) { diff --git a/src/backend/tests/hosts/connect/key-then-password.test.ts b/src/backend/tests/hosts/connect/key-then-password.test.ts new file mode 100644 index 000000000..0ae2c1820 --- /dev/null +++ b/src/backend/tests/hosts/connect/key-then-password.test.ts @@ -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 | null = null; +afterEach(() => server?.close()); + +/** A server with AuthenticationMethods publickey,password. */ +function start(): Promise { + 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((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); + }); +}); diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index f42ecf8ba..8353f1491 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -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(); }); diff --git a/src/backend/tests/utils/database-save-trigger.test.ts b/src/backend/tests/utils/database-save-trigger.test.ts index 90dcd2702..8ab4d0b66 100644 --- a/src/backend/tests/utils/database-save-trigger.test.ts +++ b/src/backend/tests/utils/database-save-trigger.test.ts @@ -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((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(); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index b21087a55..e6ebdc6b4 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -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 { - 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((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)) { diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts index 2d6c5a9fd..f0dfb7a15 100644 --- a/src/backend/utils/database-save-trigger.ts +++ b/src/backend/utils/database-save-trigger.ts @@ -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) | null = null; @@ -14,6 +25,7 @@ export class DatabaseSaveTrigger { private static activeSave: Promise | 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; 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/plugins/plugin-model.ts b/src/ui/plugins/plugin-model.ts index 7a0a68743..f0a4532ab 100644 --- a/src/ui/plugins/plugin-model.ts +++ b/src/ui/plugins/plugin-model.ts @@ -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 ?? [], 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(); + }); +}); diff --git a/src/ui/tests/plugins/plugin-model.test.ts b/src/ui/tests/plugins/plugin-model.test.ts index ea95a7f20..660bc9755 100644 --- a/src/ui/tests/plugins/plugin-model.test.ts +++ b/src/ui/tests/plugins/plugin-model.test.ts @@ -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);