mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
273 lines
7.6 KiB
TypeScript
273 lines
7.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
SEP,
|
|
parseSessions,
|
|
parseWindows,
|
|
parsePanes,
|
|
parsePsOutput,
|
|
parseGpuOutput,
|
|
buildPaneMetrics,
|
|
attachPanesToWindows,
|
|
shellEscape,
|
|
type ProcessInfo,
|
|
type TmuxWindow,
|
|
} from "../../../hosts/tmux/monitor-helpers.js";
|
|
|
|
function join(...fields: (string | number)[]): string {
|
|
return fields.join(SEP);
|
|
}
|
|
|
|
describe("parseSessions", () => {
|
|
it("parses tmux list-sessions output", () => {
|
|
const output = [
|
|
join("training", 1760000000, 1760001000, 1),
|
|
join("lab|with|pipes", 1760000500, 1760002000, 0),
|
|
].join("\n");
|
|
|
|
const sessions = parseSessions(output);
|
|
expect(sessions).toHaveLength(2);
|
|
expect(sessions[0]).toEqual({
|
|
name: "training",
|
|
created: 1760000000,
|
|
lastActivity: 1760001000,
|
|
attachedClients: 1,
|
|
});
|
|
// Session names containing "|" survive because SEP is a multi-char token
|
|
expect(sessions[1].name).toBe("lab|with|pipes");
|
|
expect(sessions[1].attachedClients).toBe(0);
|
|
});
|
|
|
|
it("returns empty array for empty output", () => {
|
|
expect(parseSessions("")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("parseWindows", () => {
|
|
it("groups windows by session", () => {
|
|
const output = [
|
|
join("training", 0, 1, "vim"),
|
|
join("training", 1, 0, "logs"),
|
|
join("api", 0, 1, "server"),
|
|
].join("\n");
|
|
|
|
const windows = parseWindows(output);
|
|
expect(windows.get("training")).toHaveLength(2);
|
|
expect(windows.get("training")![0]).toMatchObject({
|
|
index: 0,
|
|
name: "vim",
|
|
active: true,
|
|
});
|
|
expect(windows.get("api")![0].name).toBe("server");
|
|
});
|
|
});
|
|
|
|
describe("parsePanes", () => {
|
|
it("parses full pane lines including free-text fields", () => {
|
|
const output = join(
|
|
"training",
|
|
0,
|
|
"%3",
|
|
1,
|
|
12345,
|
|
1,
|
|
120,
|
|
40,
|
|
"python",
|
|
"/home/user/my|dir",
|
|
"gpu01: train.py",
|
|
);
|
|
|
|
const panes = parsePanes(output);
|
|
expect(panes).toHaveLength(1);
|
|
expect(panes[0]).toEqual({
|
|
sessionName: "training",
|
|
windowIndex: 0,
|
|
id: "%3",
|
|
index: 1,
|
|
pid: 12345,
|
|
active: true,
|
|
width: 120,
|
|
height: 40,
|
|
command: "python",
|
|
path: "/home/user/my|dir",
|
|
title: "gpu01: train.py",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parsePsOutput", () => {
|
|
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
|
|
const output = [
|
|
" 1 0 0.0 0.1 1234 systemd",
|
|
"12345 1 2.5 1.0 50000 bash",
|
|
"12400 12345 95.3 12.5 800000 python3",
|
|
"garbage line",
|
|
].join("\n");
|
|
|
|
const procs = parsePsOutput(output);
|
|
expect(procs).toHaveLength(3);
|
|
expect(procs[2]).toEqual({
|
|
pid: 12400,
|
|
ppid: 12345,
|
|
cpu: 95.3,
|
|
mem: 12.5,
|
|
rss: 800000,
|
|
comm: "python3",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseGpuOutput", () => {
|
|
it("parses nvidia-smi csv output and sums per pid", () => {
|
|
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
|
|
const gpu = parseGpuOutput(output);
|
|
expect(gpu.get(12400)).toBe(10000);
|
|
expect(gpu.get(99999)).toBe(512);
|
|
});
|
|
|
|
it("handles empty output (no GPU)", () => {
|
|
expect(parseGpuOutput("").size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("buildPaneMetrics", () => {
|
|
const panes = parsePanes(
|
|
[
|
|
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
|
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
|
|
].join("\n"),
|
|
);
|
|
|
|
const processes = parsePsOutput(
|
|
[
|
|
// pane %1: bash(100) -> python3(110) -> worker(111)
|
|
" 100 1 0.1 0.1 4000 bash",
|
|
" 110 100 90.0 10.0 700000 python3",
|
|
" 111 110 9.5 2.0 100000 dataloader",
|
|
// pane %2: bash(200) only
|
|
" 200 1 0.0 0.1 4000 bash",
|
|
// unrelated process
|
|
" 300 1 50.0 5.0 200000 chrome",
|
|
].join("\n"),
|
|
);
|
|
|
|
it("aggregates descendant trees per pane", () => {
|
|
const metrics = buildPaneMetrics(panes, processes, new Map());
|
|
const m1 = metrics.find((m) => m.paneId === "%1")!;
|
|
expect(m1.processCount).toBe(3);
|
|
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
|
|
expect(m1.memRssKb).toBe(804000);
|
|
expect(m1.topCommand).toBe("python3");
|
|
|
|
const m2 = metrics.find((m) => m.paneId === "%2")!;
|
|
expect(m2.processCount).toBe(1);
|
|
expect(m2.cpuPercent).toBe(0);
|
|
// Unrelated process is never attributed
|
|
expect(m2.memRssKb).toBe(4000);
|
|
});
|
|
|
|
it("attributes GPU memory through the process tree", () => {
|
|
const gpu = new Map([
|
|
[110, 8000],
|
|
[300, 4000],
|
|
]);
|
|
const metrics = buildPaneMetrics(panes, processes, gpu);
|
|
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
|
|
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
|
|
});
|
|
|
|
it("handles a pane whose pid is missing from ps output", () => {
|
|
const orphan = parsePanes(
|
|
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
|
|
);
|
|
const metrics = buildPaneMetrics(orphan, processes, new Map());
|
|
expect(metrics[0].processCount).toBe(0);
|
|
expect(metrics[0].cpuPercent).toBe(0);
|
|
expect(metrics[0].topCommand).toBeNull();
|
|
});
|
|
|
|
it("does not loop on cyclic ppid data", () => {
|
|
const cyclic = parsePsOutput(
|
|
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
|
|
);
|
|
const pane = parsePanes(
|
|
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
|
|
);
|
|
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
|
expect(metrics[0].processCount).toBe(2);
|
|
});
|
|
|
|
it("aggregates a wide process tree without dropping children", () => {
|
|
const childCount = 2_000;
|
|
const wideTree: ProcessInfo[] = [
|
|
{ pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" },
|
|
...Array.from({ length: childCount }, (_, index) => ({
|
|
pid: index + 2,
|
|
ppid: 1,
|
|
cpu: 0.1,
|
|
mem: 0,
|
|
rss: 1,
|
|
comm: `worker-${index}`,
|
|
})),
|
|
];
|
|
const pane = parsePanes(
|
|
join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"),
|
|
);
|
|
|
|
const [metrics] = buildPaneMetrics(pane, wideTree, new Map());
|
|
expect(metrics.processCount).toBe(childCount + 1);
|
|
expect(metrics.memRssKb).toBe(childCount + 1);
|
|
});
|
|
});
|
|
|
|
describe("attachPanesToWindows", () => {
|
|
it("places panes into their windows", () => {
|
|
const windows = parseWindows(
|
|
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
|
|
);
|
|
const panes = parsePanes(
|
|
[
|
|
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
|
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
|
|
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
|
|
].join("\n"),
|
|
);
|
|
|
|
attachPanesToWindows(windows, panes);
|
|
expect(windows.get("s1")![0].panes).toHaveLength(1);
|
|
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
|
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
|
|
});
|
|
|
|
it("preserves first-match behavior for duplicate window indexes", () => {
|
|
const first: TmuxWindow = {
|
|
index: 0,
|
|
name: "first",
|
|
active: true,
|
|
panes: [],
|
|
};
|
|
const duplicate: TmuxWindow = {
|
|
index: 0,
|
|
name: "duplicate",
|
|
active: false,
|
|
panes: [],
|
|
};
|
|
const windows = new Map([["s1", [first, duplicate]]]);
|
|
const panes = parsePanes(
|
|
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
|
);
|
|
|
|
attachPanesToWindows(windows, panes);
|
|
expect(first.panes).toHaveLength(1);
|
|
expect(duplicate.panes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("shellEscape", () => {
|
|
it("wraps in single quotes and escapes embedded quotes", () => {
|
|
expect(shellEscape("simple")).toBe("'simple'");
|
|
expect(shellEscape("it's")).toBe("'it'\\''s'");
|
|
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
|
|
});
|
|
});
|