mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f37f8511fa | ||
|
|
c759a147c6 | ||
|
|
cba845a7b8 |
No files matched your search
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 26.10.6
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
cask "termix" do
|
cask "termix" do
|
||||||
version "26.10.5"
|
version "26.10.6"
|
||||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
sha256 "970d579c438b20fc364912ac53b15f55384cc9b1c169d73320d845af3d9e5be8"
|
||||||
|
|
||||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||||
name "Termix"
|
name "Termix"
|
||||||
|
|||||||
@@ -127,15 +127,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
<td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
<td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||||
<td>Apple App Store | DMG | Homebrew</td>
|
<td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.6",
|
"version": "26.10.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.6",
|
"version": "26.10.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.6",
|
"version": "26.10.7",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFullBuilds(version) {
|
||||||
|
const base = `https://github.com/Termix-SSH/Termix/releases/download/v${version}`;
|
||||||
|
const link = (label, file) => `[${label}](${base}/${file})`;
|
||||||
|
return [
|
||||||
|
"The builds above include the standard plugins. The full builds include every official plugin, which helps on a network with no internet. You can still turn plugins on or off in the Plugins tab. Full builds are x64 only (universal on Mac):",
|
||||||
|
"",
|
||||||
|
`- Windows: ${link("EXE", "termix_windows_x64_full_nsis.exe")}`,
|
||||||
|
`- Linux: ${link("AppImage", "termix_linux_x64_full_appimage.AppImage")} · ${link("DEB", "termix_linux_x64_full_deb.deb")}`,
|
||||||
|
`- Mac: ${link("DMG", "termix_macos_universal_full_dmg.dmg")}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||||
const notes = changelogSection(changelog, version);
|
const notes = changelogSection(changelog, version);
|
||||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||||
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
|||||||
"### Downloads",
|
"### Downloads",
|
||||||
"",
|
"",
|
||||||
buildTable(version, mobileVersion, mobileTag),
|
buildTable(version, mobileVersion, mobileTag),
|
||||||
|
"",
|
||||||
|
buildFullBuilds(version),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ describe("buildBody", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links the full builds", () => {
|
||||||
|
const body = buildBody({
|
||||||
|
version: "26.10.0",
|
||||||
|
mobileVersion: "1.2.0",
|
||||||
|
changelog: CHANGELOG,
|
||||||
|
});
|
||||||
|
expect(body).toContain("v26.10.0/termix_windows_x64_full_nsis.exe");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_appimage.AppImage");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_deb.deb");
|
||||||
|
expect(body).toContain("v26.10.0/termix_macos_universal_full_dmg.dmg");
|
||||||
|
});
|
||||||
|
|
||||||
it("works for the real changelog", () => {
|
it("works for the real changelog", () => {
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const body = buildBody({
|
const body = buildBody({
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class DatabaseHealthMonitor {
|
|||||||
private static instance: DatabaseHealthMonitor;
|
private static instance: DatabaseHealthMonitor;
|
||||||
private listeners: Map<string, EventListener[]> = new Map();
|
private listeners: Map<string, EventListener[]> = new Map();
|
||||||
private degradedActive: boolean = false;
|
private degradedActive: boolean = false;
|
||||||
|
private probe: (() => Promise<boolean>) | null = null;
|
||||||
|
private probing: Promise<void> | null = null;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
@@ -83,22 +85,48 @@ class DatabaseHealthMonitor {
|
|||||||
errorCode === "DATABASE_ERROR" ||
|
errorCode === "DATABASE_ERROR" ||
|
||||||
errorCode === "DB_CONNECTION_FAILED";
|
errorCode === "DB_CONNECTION_FAILED";
|
||||||
|
|
||||||
|
// A cancel is on purpose and says nothing about the backend.
|
||||||
|
if (errorCode === "ERR_CANCELED" || lowerMessage === "canceled") return;
|
||||||
|
|
||||||
const isBackendUnreachable =
|
const isBackendUnreachable =
|
||||||
errorCode === "ERR_NETWORK" ||
|
errorCode === "ERR_NETWORK" ||
|
||||||
errorCode === "ECONNREFUSED" ||
|
errorCode === "ECONNREFUSED" ||
|
||||||
errorCode === "ECONNABORTED" ||
|
|
||||||
errorCode === "ECONNRESET" ||
|
errorCode === "ECONNRESET" ||
|
||||||
errorCode === "ETIMEDOUT" ||
|
|
||||||
errorCode === "ERR_CANCELED" ||
|
|
||||||
(lowerMessage.includes("network error") &&
|
(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("request aborted") ||
|
||||||
lowerMessage.includes("timeout");
|
lowerMessage.includes("timeout");
|
||||||
|
|
||||||
if (!(isDatabaseError || isBackendUnreachable)) {
|
if (isDatabaseError || isBackendUnreachable) {
|
||||||
return;
|
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) {
|
if (!this.degradedActive) {
|
||||||
this.degradedActive = true;
|
this.degradedActive = true;
|
||||||
this.emit("database-connection-degraded", {
|
this.emit("database-connection-degraded", {
|
||||||
|
|||||||
@@ -676,6 +676,18 @@ function initializeApiInstances() {
|
|||||||
|
|
||||||
// Authentication API (port 30001)
|
// Authentication API (port 30001)
|
||||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
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)
|
// RBAC API (port 30001)
|
||||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user