mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat(tmux-monitor): add a unified multi-host view (#1520)
This commit is contained in:
1 parent
cbbb80bc82
commit
4c2f54dac0
12 files changed
+1192
-115
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readExpanded,
|
||||
readSelectedPane,
|
||||
readMonitorValue,
|
||||
saveMonitorValue,
|
||||
} from "../../src/frontend/monitor-storage";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
describe("per-host monitor preferences", () => {
|
||||
it("keeps identically named sessions and panes separate by host", () => {
|
||||
saveMonitorValue("expanded-1", ["same"]);
|
||||
saveMonitorValue("expanded-2", []);
|
||||
saveMonitorValue("pane-1", {
|
||||
paneId: "%0",
|
||||
sessionName: "same",
|
||||
windowIndex: 0,
|
||||
});
|
||||
expect(readExpanded(1)).toEqual(new Set(["same"]));
|
||||
expect(readExpanded(2)).toEqual(new Set());
|
||||
expect(readSelectedPane(1)?.paneId).toBe("%0");
|
||||
expect(readSelectedPane(2)).toBeNull();
|
||||
});
|
||||
it("tolerates invalid or obsolete stored values", () => {
|
||||
localStorage.setItem("termix-tmux-monitor-filter", "{broken");
|
||||
expect(readMonitorValue("filter", "")).toBe("");
|
||||
saveMonitorValue("pane-1", { paneId: "%0", windowIndex: "0" });
|
||||
expect(readSelectedPane(1)).toBeNull();
|
||||
saveMonitorValue("expanded-1", ["same", null, 3]);
|
||||
expect(readExpanded(1)).toEqual(new Set(["same"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { PanePreview } from "../../src/frontend/PanePreview";
|
||||
import type { SessionTree } from "../../src/frontend/SessionTree";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TmuxMonitor } from "../../src/frontend/TmuxMonitor";
|
||||
import * as api from "../../src/frontend/api";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: [1, 2].map((id) => ({
|
||||
id,
|
||||
name: `Host ${id}`,
|
||||
ip: `host${id}`,
|
||||
enableSsh: true,
|
||||
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: true } },
|
||||
})),
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (original) => ({
|
||||
...(await original<object>()),
|
||||
useHosts: () => ({ hosts: state.hosts, loaded: true }),
|
||||
useToast: () => state.toast,
|
||||
useTranslation: () => ({ t: state.t }),
|
||||
}));
|
||||
vi.mock("../../src/frontend/api", () => ({
|
||||
getTmuxOverview: vi.fn(),
|
||||
getTmuxMetrics: vi.fn(),
|
||||
focusTmuxPane: vi.fn(),
|
||||
searchTmux: vi.fn(),
|
||||
setTmuxSessionTags: vi.fn(),
|
||||
createTmuxSession: vi.fn(),
|
||||
createTmuxWindow: vi.fn(),
|
||||
renameTmuxSession: vi.fn(),
|
||||
killTmuxSession: vi.fn(),
|
||||
killTmuxWindow: vi.fn(),
|
||||
killTmuxPane: vi.fn(),
|
||||
splitTmuxPane: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/frontend/use-adaptive-polling", async () => {
|
||||
const { useEffect, useRef } = await import("react");
|
||||
return {
|
||||
useAdaptivePolling: (
|
||||
poll: () => Promise<unknown>,
|
||||
_policy: unknown,
|
||||
enabled: boolean,
|
||||
options?: { runImmediately?: boolean },
|
||||
) => {
|
||||
const ref = useRef(poll);
|
||||
ref.current = poll;
|
||||
useEffect(() => {
|
||||
if (enabled && options?.runImmediately !== false)
|
||||
void Promise.resolve(ref.current()).catch(() => {});
|
||||
}, [enabled, options?.runImmediately]);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../src/frontend/PanePreview", () => ({
|
||||
PanePreview: ({
|
||||
host,
|
||||
pane,
|
||||
metrics,
|
||||
}: ComponentProps<typeof PanePreview>) => (
|
||||
<div data-testid="preview">
|
||||
{host.id}:{pane.paneId}:{metrics?.cpuPercent ?? "none"}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../src/frontend/SessionTree", () => ({
|
||||
SessionTree: (props: ComponentProps<typeof SessionTree>) => (
|
||||
<div>
|
||||
<span>cpu:{props.metricsByPane.get("%0")?.cpuPercent ?? "none"}</span>
|
||||
<span>
|
||||
{props.expandedSessions.has("same") ? "expanded" : "collapsed"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onSelectPane({
|
||||
paneId: "%0",
|
||||
sessionName: "same",
|
||||
windowIndex: 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
pane
|
||||
</button>
|
||||
<button onClick={() => props.onSelectSession?.("same")}>session</button>
|
||||
<button onClick={() => props.onNewWindow("same")}>new window</button>
|
||||
<button onClick={() => props.onKillSession("same")}>kill session</button>
|
||||
<button onClick={() => props.onToggleSession("same")}>
|
||||
toggle session
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function overview(): api.TmuxOverview {
|
||||
return {
|
||||
available: true,
|
||||
sessions: [
|
||||
{
|
||||
name: "same",
|
||||
created: 1,
|
||||
lastActivity: 1,
|
||||
attachedClients: 0,
|
||||
tags: [],
|
||||
windows: [
|
||||
{
|
||||
index: 0,
|
||||
name: "window",
|
||||
active: true,
|
||||
panes: [
|
||||
{
|
||||
id: "%0",
|
||||
index: 0,
|
||||
pid: 1,
|
||||
active: true,
|
||||
width: 80,
|
||||
height: 24,
|
||||
command: "bash",
|
||||
path: "/",
|
||||
title: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function metrics(id: number): api.TmuxPaneMetrics[] {
|
||||
return [
|
||||
{
|
||||
paneId: "%0",
|
||||
sessionName: "same",
|
||||
pid: 1,
|
||||
processCount: 1,
|
||||
cpuPercent: id * 10,
|
||||
memRssKb: id,
|
||||
gpuMemMb: 0,
|
||||
topCommand: "bash",
|
||||
},
|
||||
];
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
const host = (id: number) =>
|
||||
within(screen.getByRole("region", { name: `Host ${id}` }));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.mocked(api.getTmuxOverview).mockImplementation(async () => overview());
|
||||
vi.mocked(api.getTmuxMetrics).mockImplementation(async (id) => metrics(id));
|
||||
vi.mocked(api.focusTmuxPane).mockResolvedValue();
|
||||
vi.mocked(api.createTmuxWindow).mockResolvedValue();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("multi-host tmux monitor", () => {
|
||||
it("isolates duplicate session/pane IDs and routes preview and management to their host", async () => {
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() =>
|
||||
expect(host(1).getByText("cpu:10")).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(host(2).getByText("cpu:20")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(host(2).getByText("pane"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"),
|
||||
);
|
||||
expect(api.focusTmuxPane).toHaveBeenLastCalledWith(2, "%0");
|
||||
await waitFor(() =>
|
||||
expect(host(1).getByText("new window")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(host(1).getByText("new window"));
|
||||
await waitFor(() =>
|
||||
expect(api.createTmuxWindow).toHaveBeenCalledExactlyOnceWith(1, "same"),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the last host, pane, filter and expanded sessions", async () => {
|
||||
const first = render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
fireEvent.click(host(2).getByText("pane"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||
);
|
||||
fireEvent.click(host(2).getByText("toggle session"));
|
||||
fireEvent.change(screen.getByLabelText("tmuxMonitor.filterHosts"), {
|
||||
target: { value: "Host 2" },
|
||||
});
|
||||
first.unmount();
|
||||
render(<TmuxMonitor />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||
);
|
||||
expect(screen.getByLabelText("tmuxMonitor.filterHosts")).toHaveValue(
|
||||
"Host 2",
|
||||
);
|
||||
expect(host(2).getByText("collapsed")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Host 1" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores late metrics from a previously selected host", async () => {
|
||||
const old = deferred<api.TmuxPaneMetrics[]>();
|
||||
vi.mocked(api.getTmuxMetrics).mockImplementation((id) =>
|
||||
id === 1 ? old.promise : Promise.resolve(metrics(id)),
|
||||
);
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
fireEvent.click(host(2).getByText("pane"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"),
|
||||
);
|
||||
await act(async () => old.resolve(metrics(9)));
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20");
|
||||
});
|
||||
|
||||
it("does not execute an abandoned cross-host action after its overview arrives", async () => {
|
||||
const delayed = deferred<api.TmuxOverview>();
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
vi.mocked(api.getTmuxOverview).mockImplementation((id) =>
|
||||
id === 2 ? delayed.promise : Promise.resolve(overview()),
|
||||
);
|
||||
fireEvent.click(host(2).getByText("new window"));
|
||||
fireEvent.click(host(1).getByRole("button", { name: "Host 1" }));
|
||||
await act(async () => delayed.resolve(overview()));
|
||||
expect(api.createTmuxWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isolates an unavailable host and stops loading collapsed, filtered and hidden hosts", async () => {
|
||||
localStorage.setItem("termix-tmux-monitor-collapsed-hosts", '["2"]');
|
||||
const app = render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||
expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2);
|
||||
vi.mocked(api.getTmuxOverview).mockImplementation(async (id) => {
|
||||
if (id === 2) throw new Error("offline");
|
||||
return overview();
|
||||
});
|
||||
fireEvent.click(host(2).getByLabelText("tmuxMonitor.toggleHost"));
|
||||
await waitFor(() =>
|
||||
expect(host(2).getByRole("status")).toHaveTextContent(
|
||||
"tmuxMonitor.failedToLoad",
|
||||
),
|
||||
);
|
||||
expect(host(1).getByText("pane")).toBeInTheDocument();
|
||||
app.rerender(<TmuxMonitor initialHostId={1} isVisible={false} />);
|
||||
expect(host(2).queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirms a destructive action on the host selected from the background tree", async () => {
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() =>
|
||||
expect(host(2).getByText("kill session")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(host(2).getByText("kill session"));
|
||||
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "tmuxMonitor.kill",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.killTmuxSession).toHaveBeenCalledExactlyOnceWith(2, "same"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore a pane removed from the server", async () => {
|
||||
localStorage.setItem(
|
||||
"termix-tmux-monitor-pane-1",
|
||||
JSON.stringify({ paneId: "%99", sessionName: "same", windowIndex: 0 }),
|
||||
);
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("preview")).not.toBeInTheDocument();
|
||||
expect(api.focusTmuxPane).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores an old overview after switching away and back to the same host", async () => {
|
||||
const old = deferred<api.TmuxOverview>();
|
||||
let first = true;
|
||||
vi.mocked(api.getTmuxOverview).mockImplementation((id) => {
|
||||
if (id === 1 && first) {
|
||||
first = false;
|
||||
return old.promise;
|
||||
}
|
||||
return Promise.resolve(overview());
|
||||
});
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
fireEvent.click(host(2).getByText("pane"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||
);
|
||||
fireEvent.click(host(1).getByRole("button", { name: "Host 1" }));
|
||||
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||
await act(async () => old.resolve({ available: false, sessions: [] }));
|
||||
expect(host(1).getByText("pane")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("honors a repeated host action after switching manually", async () => {
|
||||
const app = render(
|
||||
<TmuxMonitor initialHostId={1} initialHostRequest={1} />,
|
||||
);
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
fireEvent.click(host(2).getByText("pane"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||
);
|
||||
app.rerender(<TmuxMonitor initialHostId={1} initialHostRequest={2} />);
|
||||
await waitFor(() =>
|
||||
expect(host(1).getByRole("button", { name: "Host 1" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains single-host mode without loading other hosts", async () => {
|
||||
localStorage.setItem("termix-tmux-monitor-scope", '"single"');
|
||||
render(<TmuxMonitor initialHostId={1} />);
|
||||
await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument());
|
||||
expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2);
|
||||
fireEvent.change(screen.getByLabelText("tmuxMonitor.selectHost"), {
|
||||
target: { value: "2" },
|
||||
});
|
||||
await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument());
|
||||
fireEvent.click(host(2).getByText("session"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPollQueue } from "../../src/frontend/poll-queue";
|
||||
|
||||
describe("background host polling queue", () => {
|
||||
it("bounds concurrent hosts and releases a slot after failure", async () => {
|
||||
const run = createPollQueue(2);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const first = run(() => held);
|
||||
const second = run(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
const thirdTask = vi.fn(async () => "third");
|
||||
const third = run(thirdTask);
|
||||
expect(thirdTask).not.toHaveBeenCalled();
|
||||
await expect(second).rejects.toThrow("offline");
|
||||
await expect(third).resolves.toBe("third");
|
||||
expect(thirdTask).toHaveBeenCalledOnce();
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { SessionTree } from "../../src/frontend/SessionTree";
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (original) => ({
|
||||
...(await original<object>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
it("selects a session by its label while the chevron only changes expansion", () => {
|
||||
const select = vi.fn();
|
||||
const toggle = vi.fn();
|
||||
const noop = () => {};
|
||||
render(
|
||||
<SessionTree
|
||||
sessions={[
|
||||
{
|
||||
name: "same",
|
||||
created: 1,
|
||||
lastActivity: 1,
|
||||
attachedClients: 0,
|
||||
windows: [],
|
||||
tags: [],
|
||||
},
|
||||
]}
|
||||
expandedSessions={new Set()}
|
||||
onToggleSession={toggle}
|
||||
selectedPaneId={null}
|
||||
onSelectPane={noop}
|
||||
metricsByPane={new Map()}
|
||||
metricsBySession={new Map()}
|
||||
onSelectSession={select}
|
||||
onEditTags={noop}
|
||||
onAttachSession={noop}
|
||||
onNewWindow={noop}
|
||||
onRenameSession={noop}
|
||||
onKillSession={noop}
|
||||
onKillPane={noop}
|
||||
onSplitPane={noop}
|
||||
onKillWindow={noop}
|
||||
compact={false}
|
||||
now={1}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("same"));
|
||||
expect(select).toHaveBeenCalledExactlyOnceWith("same");
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByLabelText("tmuxMonitor.toggleSession"));
|
||||
expect(toggle).toHaveBeenCalledExactlyOnceWith("same");
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
});
|
||||
Reference in new issue
Block a user