mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
8b6037b7ff
- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged.
226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
SEP,
|
|
parseSessions,
|
|
parseWindows,
|
|
parsePanes,
|
|
parsePsOutput,
|
|
parseGpuOutput,
|
|
buildPaneMetrics,
|
|
attachPanesToWindows,
|
|
shellEscape,
|
|
} from "./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);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
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 /)'");
|
|
});
|
|
});
|