mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: convert tmux monitoring into a plugin (B10)
This commit is contained in:
1 parent
79d77678c9
commit
708eedb861
145 files changed
+30308
-4458
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { sessionTags } from "./tables.js";
|
||||
import { createSessionTagRepository } from "./repository.js";
|
||||
import { createTmuxMonitorRoutes } from "./routes.js";
|
||||
import { createTmuxSessionsService } from "./service.js";
|
||||
|
||||
export type { TmuxDetection, TmuxSessionsV1 } from "./service.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(sessionTags);
|
||||
const tags = createSessionTagRepository(ctx.db, table);
|
||||
|
||||
ctx.http.router<Router>().use(createTmuxMonitorRoutes(ctx, tags));
|
||||
|
||||
ctx.services.provide("tmux.sessions", createTmuxSessionsService(ctx));
|
||||
|
||||
ctx.log.info("Tmux Monitor routes mounted at /plugin-api/tmux-monitor");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Pure parsing/aggregation helpers for the tmux monitor module.
|
||||
// Kept free of SSH/Express dependencies so they can be unit-tested.
|
||||
|
||||
// Field separator used in tmux -F format strings. Session names, pane titles
|
||||
// and paths may contain "|", and tmux sanitizes control characters (and, under
|
||||
// non-UTF-8 locales, multibyte characters) in format output to "_", so a
|
||||
// printable ASCII token is the only separator that survives everywhere.
|
||||
export const SEP = "<<TMX>>";
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
index: number;
|
||||
pid: number;
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
command: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
index: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
panes: TmuxPane[];
|
||||
}
|
||||
|
||||
export interface TmuxSessionSummary {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface RawPane extends TmuxPane {
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
comm: string;
|
||||
}
|
||||
|
||||
export interface PaneMetrics {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
pid: number;
|
||||
processCount: number;
|
||||
cpuPercent: number;
|
||||
memRssKb: number;
|
||||
gpuMemMb: number;
|
||||
topCommand: string | null;
|
||||
}
|
||||
|
||||
export function parseSessions(output: string): TmuxSessionSummary[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, created, activity, attached] = line.split(SEP);
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWindows(output: string): Map<string, TmuxWindow[]> {
|
||||
const bySession = new Map<string, TmuxWindow[]>();
|
||||
for (const line of output.split("\n").filter(Boolean)) {
|
||||
const [session, index, active, name] = line.split(SEP);
|
||||
if (!bySession.has(session)) bySession.set(session, []);
|
||||
bySession.get(session)!.push({
|
||||
index: parseInt(index, 10) || 0,
|
||||
name: name || "",
|
||||
active: active === "1",
|
||||
panes: [],
|
||||
});
|
||||
}
|
||||
return bySession;
|
||||
}
|
||||
|
||||
export function parsePanes(output: string): RawPane[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [
|
||||
sessionName,
|
||||
windowIndex,
|
||||
id,
|
||||
index,
|
||||
pid,
|
||||
active,
|
||||
width,
|
||||
height,
|
||||
command,
|
||||
path,
|
||||
title,
|
||||
] = line.split(SEP);
|
||||
return {
|
||||
sessionName,
|
||||
windowIndex: parseInt(windowIndex, 10) || 0,
|
||||
id,
|
||||
index: parseInt(index, 10) || 0,
|
||||
pid: parseInt(pid, 10) || 0,
|
||||
active: active === "1",
|
||||
width: parseInt(width, 10) || 0,
|
||||
height: parseInt(height, 10) || 0,
|
||||
command: command || "",
|
||||
path: path || "",
|
||||
title: title || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parsePsOutput(output: string): ProcessInfo[] {
|
||||
const processes: ProcessInfo[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 6) continue;
|
||||
const pid = parseInt(parts[0], 10);
|
||||
const ppid = parseInt(parts[1], 10);
|
||||
if (isNaN(pid) || isNaN(ppid)) continue;
|
||||
processes.push({
|
||||
pid,
|
||||
ppid,
|
||||
cpu: parseFloat(parts[2]) || 0,
|
||||
mem: parseFloat(parts[3]) || 0,
|
||||
rss: parseInt(parts[4], 10) || 0,
|
||||
comm: parts.slice(5).join(" "),
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function parseGpuOutput(output: string): Map<number, number> {
|
||||
const gpuByPid = new Map<number, number>();
|
||||
for (const line of output.split("\n").filter(Boolean)) {
|
||||
const [pid, mem] = line.split(",").map((s) => s.trim());
|
||||
const pidNum = parseInt(pid, 10);
|
||||
const memNum = parseInt(mem, 10);
|
||||
if (!isNaN(pidNum) && !isNaN(memNum)) {
|
||||
gpuByPid.set(pidNum, (gpuByPid.get(pidNum) || 0) + memNum);
|
||||
}
|
||||
}
|
||||
return gpuByPid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each pane's shell pid to its descendant process tree and aggregate
|
||||
* CPU/RAM/GPU usage per pane.
|
||||
*/
|
||||
export function buildPaneMetrics(
|
||||
panes: RawPane[],
|
||||
processes: ProcessInfo[],
|
||||
gpuByPid: Map<number, number>,
|
||||
): PaneMetrics[] {
|
||||
const byPid = new Map<number, ProcessInfo>();
|
||||
const childrenOf = new Map<number, number[]>();
|
||||
for (const p of processes) {
|
||||
byPid.set(p.pid, p);
|
||||
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
|
||||
childrenOf.get(p.ppid)!.push(p.pid);
|
||||
}
|
||||
|
||||
return panes.map((pane) => {
|
||||
// Walk the descendant tree starting at (and including) the pane's shell
|
||||
const treePids: number[] = [];
|
||||
const queue = [pane.pid];
|
||||
const seen = new Set<number>();
|
||||
for (let cursor = 0; cursor < queue.length; cursor++) {
|
||||
const pid = queue[cursor];
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
if (byPid.has(pid)) treePids.push(pid);
|
||||
for (const child of childrenOf.get(pid) || []) queue.push(child);
|
||||
}
|
||||
|
||||
let cpuPercent = 0;
|
||||
let memRssKb = 0;
|
||||
let gpuMemMb = 0;
|
||||
let topCommand: string | null = null;
|
||||
let topCpu = -1;
|
||||
for (const pid of treePids) {
|
||||
const p = byPid.get(pid)!;
|
||||
cpuPercent += p.cpu;
|
||||
memRssKb += p.rss;
|
||||
gpuMemMb += gpuByPid.get(pid) || 0;
|
||||
// The pane shell itself is rarely the interesting process
|
||||
if (p.cpu > topCpu && pid !== pane.pid) {
|
||||
topCpu = p.cpu;
|
||||
topCommand = p.comm;
|
||||
}
|
||||
}
|
||||
if (topCommand === null && treePids.length > 0) {
|
||||
topCommand = byPid.get(treePids[0])!.comm;
|
||||
}
|
||||
|
||||
return {
|
||||
paneId: pane.id,
|
||||
sessionName: pane.sessionName,
|
||||
pid: pane.pid,
|
||||
processCount: treePids.length,
|
||||
cpuPercent: Math.round(cpuPercent * 10) / 10,
|
||||
memRssKb,
|
||||
gpuMemMb,
|
||||
topCommand,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group panes into their windows (mutates the window objects' pane arrays).
|
||||
*/
|
||||
export function attachPanesToWindows(
|
||||
windows: Map<string, TmuxWindow[]>,
|
||||
panes: RawPane[],
|
||||
): void {
|
||||
const windowsBySessionAndIndex = new Map<string, Map<number, TmuxWindow>>();
|
||||
for (const [sessionName, sessionWindows] of windows) {
|
||||
const byIndex = new Map<number, TmuxWindow>();
|
||||
for (const window of sessionWindows) {
|
||||
if (!byIndex.has(window.index)) byIndex.set(window.index, window);
|
||||
}
|
||||
windowsBySessionAndIndex.set(sessionName, byIndex);
|
||||
}
|
||||
|
||||
for (const pane of panes) {
|
||||
const window = windowsBySessionAndIndex
|
||||
.get(pane.sessionName)
|
||||
?.get(pane.windowIndex);
|
||||
if (window) {
|
||||
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
||||
window.panes.push(paneFields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type SessionTagRepository = ReturnType<
|
||||
typeof createSessionTagRepository
|
||||
>;
|
||||
|
||||
/**
|
||||
* A user's tags on a tmux session, per host. Sessions are shared on the host
|
||||
* (any user monitoring it sees the same tmux sessions), but tags are per
|
||||
* user.
|
||||
*/
|
||||
export function createSessionTagRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async listByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)));
|
||||
|
||||
const bySession = new Map<string, string[]>();
|
||||
for (const row of rows as { sessionName: string; tag: string }[]) {
|
||||
const tags = bySession.get(row.sessionName) ?? [];
|
||||
tags.push(row.tag);
|
||||
bySession.set(row.sessionName, tags);
|
||||
}
|
||||
return bySession;
|
||||
},
|
||||
|
||||
async renameSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
newSessionName: string,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ sessionName: newSessionName })
|
||||
.where(
|
||||
and(eq(table.hostId, hostId), eq(table.sessionName, sessionName)),
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(
|
||||
and(eq(table.hostId, hostId), eq(table.sessionName, sessionName)),
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async replaceForUserHostSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.hostId, hostId),
|
||||
eq(table.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
if (tags.length > 0) {
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values(tags.map((tag) => ({ userId, hostId, sessionName, tag })));
|
||||
}
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
import express, { type Router } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginHostSummary,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { execCommand, tmuxCommand } from "./tmux-commands.js";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
parseWindows,
|
||||
parsePanes,
|
||||
parsePsOutput,
|
||||
parseGpuOutput,
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type RawPane,
|
||||
type TmuxSessionSummary,
|
||||
type TmuxWindow,
|
||||
type PaneMetrics,
|
||||
} from "./monitor-helpers.js";
|
||||
import type { SessionTagRepository } from "./repository.js";
|
||||
|
||||
const PANE_ID_RE = /^%\d+$/;
|
||||
// tmux session names cannot contain ":" or "."; keep to a conservative
|
||||
// printable subset so the name is safe as a tmux target everywhere.
|
||||
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
|
||||
const MAX_SEARCH_PANES = 100;
|
||||
const MAX_MATCHES_PER_PANE = 50;
|
||||
const SEARCH_HISTORY_LINES = 2000;
|
||||
const SEARCH_CONCURRENCY = 4;
|
||||
const POOL = "tmux-monitor";
|
||||
|
||||
interface TmuxSessionOverview extends TmuxSessionSummary {
|
||||
windows: TmuxWindow[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
type TmuxErrorCode =
|
||||
"TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR";
|
||||
|
||||
function classifyTmuxError(err: unknown): TmuxErrorCode {
|
||||
const msg = toErrorMessage(err);
|
||||
if (/command not found|exited with code 127/i.test(msg))
|
||||
return "TMUX_NOT_INSTALLED";
|
||||
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
|
||||
if (
|
||||
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
|
||||
msg,
|
||||
)
|
||||
)
|
||||
return "HOST_UNREACHABLE";
|
||||
return "TMUX_ERROR";
|
||||
}
|
||||
|
||||
function sendTmuxError(
|
||||
ctx: PluginContext,
|
||||
res: express.Response,
|
||||
err: unknown,
|
||||
context: string,
|
||||
hostId: number,
|
||||
): void {
|
||||
const code = classifyTmuxError(err);
|
||||
const status = code === "TMUX_ERROR" ? 500 : 503;
|
||||
const error =
|
||||
code === "TMUX_NOT_INSTALLED"
|
||||
? "tmux is not installed on this host"
|
||||
: code === "TMUX_NO_SERVER"
|
||||
? "No tmux server is running on this host"
|
||||
: code === "HOST_UNREACHABLE"
|
||||
? "Could not connect to the host"
|
||||
: toErrorMessage(err);
|
||||
ctx.log.error(`tmux ${context} failed for host ${hostId}`, err as Error);
|
||||
res.status(status).json({ error, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the host for the acting user and checks the monitor is enabled for
|
||||
* it. Sends the error response and returns null when access is denied.
|
||||
*/
|
||||
async function requireHost(
|
||||
ctx: PluginContext,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<PluginHostSummary | null> {
|
||||
const hostId = parseInt(String(req.params.hostId), 10);
|
||||
if (isNaN(hostId)) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = await ctx.hosts.get(hostId);
|
||||
if (!host) {
|
||||
res.status(403).json({ error: "Host not found or access denied" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// The monitor is opt-in per host: hiding the UI is not enough, the API
|
||||
// must refuse too.
|
||||
const enabled = await ctx.settings.getHost<boolean>(
|
||||
hostId,
|
||||
"enableTmuxMonitor",
|
||||
);
|
||||
if (!enabled) {
|
||||
res
|
||||
.status(403)
|
||||
.json({ error: "Tmux Monitor is not enabled for this host" });
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
async function withHostConnection<T>(
|
||||
ctx: PluginContext,
|
||||
host: PluginHostSummary,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return ctx.ssh.withConnection<T, Client>(
|
||||
host.id,
|
||||
{ pool: POOL, purpose: "tmux" },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||
try {
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTmuxList(conn: Client, command: string): Promise<string> {
|
||||
try {
|
||||
return await execCommand(conn, command);
|
||||
} catch {
|
||||
return ""; // tmux server not running -- no sessions
|
||||
}
|
||||
}
|
||||
|
||||
function listSessionsCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
function listWindowsCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
function listPanesCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
|
||||
return parsePanes(await runTmuxList(conn, listPanesCmd()));
|
||||
}
|
||||
|
||||
async function collectPaneMetrics(
|
||||
conn: Client,
|
||||
panes: RawPane[],
|
||||
): Promise<PaneMetrics[]> {
|
||||
let psOutput = "";
|
||||
try {
|
||||
psOutput = await execCommand(
|
||||
conn,
|
||||
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
let gpuOutput = "";
|
||||
try {
|
||||
gpuOutput = await execCommand(
|
||||
conn,
|
||||
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
|
||||
);
|
||||
} catch {
|
||||
// no GPU on host
|
||||
}
|
||||
|
||||
return buildPaneMetrics(
|
||||
panes,
|
||||
parsePsOutput(psOutput),
|
||||
parseGpuOutput(gpuOutput),
|
||||
);
|
||||
}
|
||||
|
||||
export function createTmuxMonitorRoutes(
|
||||
ctx: PluginContext,
|
||||
tags: SessionTagRepository,
|
||||
): Router {
|
||||
const router = express.Router();
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/overview:
|
||||
* get:
|
||||
* summary: Get a host's tmux sessions, windows and panes
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The tmux overview for this host.
|
||||
* 403:
|
||||
* description: Host not found, not accessible, or the monitor is disabled for it.
|
||||
*/
|
||||
router.get("/:hostId/overview", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host || !userId) return;
|
||||
|
||||
try {
|
||||
const result = await withHostConnection(ctx, host, async (conn) => {
|
||||
if (!(await tmuxAvailable(conn))) {
|
||||
return { available: false, sessions: [] as TmuxSessionOverview[] };
|
||||
}
|
||||
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
|
||||
runTmuxList(conn, listSessionsCmd()),
|
||||
runTmuxList(conn, listWindowsCmd()),
|
||||
runTmuxList(conn, listPanesCmd()),
|
||||
]);
|
||||
const sessions = parseSessions(sessionsOut);
|
||||
const windows = parseWindows(windowsOut);
|
||||
attachPanesToWindows(windows, parsePanes(panesOut));
|
||||
|
||||
const sessionTags = await tags.listByUserAndHost(userId, host.id);
|
||||
const full: TmuxSessionOverview[] = sessions.map((s) => ({
|
||||
...s,
|
||||
windows: windows.get(s.name) || [],
|
||||
tags: sessionTags.get(s.name) || [],
|
||||
}));
|
||||
return { available: true, sessions: full };
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sendTmuxError(ctx, res, err, "overview", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/focus:
|
||||
* post:
|
||||
* summary: Focus a pane
|
||||
* description: Selects the pane's window and pane on the server, so every attached client switches to it.
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Focused.
|
||||
*/
|
||||
router.post("/:hostId/focus", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendTmuxError(ctx, res, err, "focus", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/sessions:
|
||||
* post:
|
||||
* summary: Create a detached tmux session
|
||||
* description: Starts the tmux server if none is running.
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session created.
|
||||
* 409:
|
||||
* description: A session with this name already exists.
|
||||
*/
|
||||
router.post("/:hostId/sessions", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const name = String((req.body as { name?: string })?.name || "").trim();
|
||||
if (!SESSION_NAME_RE.test(name)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(`new-session -d -s ${shellEscape(name)}`),
|
||||
),
|
||||
);
|
||||
ctx.log.info(`tmux session created: ${name} on host ${host.id}`);
|
||||
res.json({ ok: true, name });
|
||||
} catch (err) {
|
||||
if (/duplicate session/i.test(toErrorMessage(err))) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A session with this name already exists" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "create session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/windows:
|
||||
* post:
|
||||
* summary: Create a window in an existing session
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Window created.
|
||||
* 404:
|
||||
* description: Session not found.
|
||||
*/
|
||||
router.post("/:hostId/windows", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
(req.body as { sessionName?: string })?.sessionName || "",
|
||||
).trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
|
||||
),
|
||||
);
|
||||
ctx.log.info(
|
||||
`tmux window created in session ${sessionName} on host ${host.id}`,
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "create window", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/rename:
|
||||
* post:
|
||||
* summary: Rename a session
|
||||
* description: Saved tags follow the session to its new name, for every user monitoring this host.
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session renamed.
|
||||
* 404:
|
||||
* description: Session not found.
|
||||
* 409:
|
||||
* description: A session with this name already exists.
|
||||
*/
|
||||
router.post("/:hostId/rename", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; newName?: string };
|
||||
const sessionName = String(body?.sessionName || "").trim();
|
||||
const newName = String(body?.newName || "").trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
if (!SESSION_NAME_RE.test(newName)) {
|
||||
return res.status(400).json({ error: "Invalid new session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tags.renameSessionForHost(host.id, sessionName, newName);
|
||||
ctx.log.info(
|
||||
`tmux session renamed on host ${host.id}: ${sessionName} -> ${newName}`,
|
||||
);
|
||||
await ctx.audit.record({
|
||||
action: "tmux_session_rename",
|
||||
resourceType: "host",
|
||||
resourceId: String(host.id),
|
||||
resourceName: sessionName,
|
||||
details: JSON.stringify({ newName }),
|
||||
success: true,
|
||||
});
|
||||
res.json({ ok: true, name: newName });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
if (/duplicate session/i.test(toErrorMessage(err))) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A session with this name already exists" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "rename session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/kill:
|
||||
* post:
|
||||
* summary: Kill a session and drop its saved tags
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session killed.
|
||||
* 404:
|
||||
* description: Session not found.
|
||||
*/
|
||||
router.post("/:hostId/kill", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
(req.body as { sessionName?: string })?.sessionName || "",
|
||||
).trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
|
||||
),
|
||||
);
|
||||
await tags.deleteSessionForHost(host.id, sessionName);
|
||||
ctx.log.info(`tmux session killed on host ${host.id}: ${sessionName}`);
|
||||
await ctx.audit.record({
|
||||
action: "tmux_session_kill",
|
||||
resourceType: "host",
|
||||
resourceId: String(host.id),
|
||||
resourceName: sessionName,
|
||||
success: true,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "kill session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/kill-window:
|
||||
* post:
|
||||
* summary: Kill a window and every pane in it
|
||||
* description: Killing the last window of a session ends the session (tmux semantics).
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Window killed.
|
||||
* 404:
|
||||
* description: Window not found.
|
||||
*/
|
||||
router.post("/:hostId/kill-window", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; windowIndex?: number };
|
||||
const sessionName = String(body?.sessionName || "").trim();
|
||||
const windowIndex = Number(body?.windowIndex);
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
|
||||
return res.status(400).json({ error: "Invalid window index" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
ctx.log.info(
|
||||
`tmux window killed on host ${host.id}: ${sessionName}:${windowIndex}`,
|
||||
);
|
||||
await ctx.audit.record({
|
||||
action: "tmux_window_kill",
|
||||
resourceType: "host",
|
||||
resourceId: String(host.id),
|
||||
resourceName: sessionName,
|
||||
details: JSON.stringify({ windowIndex }),
|
||||
success: true,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (
|
||||
/can't find window|no such window|can't find session/i.test(
|
||||
toErrorMessage(err),
|
||||
)
|
||||
) {
|
||||
return res.status(404).json({ error: "Window not found" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "kill window", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/kill-pane:
|
||||
* post:
|
||||
* summary: Kill a single pane
|
||||
* description: Killing the last pane of a window closes the window, and the last window of a session ends the session (tmux semantics).
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Pane killed.
|
||||
* 404:
|
||||
* description: Pane not found.
|
||||
*/
|
||||
router.post("/:hostId/kill-pane", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
|
||||
);
|
||||
ctx.log.info(`tmux pane killed on host ${host.id}: ${paneId}`);
|
||||
await ctx.audit.record({
|
||||
action: "tmux_pane_kill",
|
||||
resourceType: "host",
|
||||
resourceId: String(host.id),
|
||||
resourceName: paneId,
|
||||
success: true,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Pane not found" });
|
||||
}
|
||||
sendTmuxError(ctx, res, err, "kill pane", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/split:
|
||||
* post:
|
||||
* summary: Split the window containing a pane
|
||||
* description: "\"h\" places the new pane to the right, \"v\" below, matching tmux's own -h/-v semantics. The new pane starts in the source pane's working directory."
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Pane split.
|
||||
*/
|
||||
router.post("/:hostId/split", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { paneId?: string; direction?: string };
|
||||
const paneId = String(body?.paneId || "");
|
||||
const direction = body?.direction === "v" ? "-v" : "-h";
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
if (body?.direction !== "h" && body?.direction !== "v") {
|
||||
return res.status(400).json({ error: "Invalid split direction" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(ctx, host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
ctx.log.info(
|
||||
`tmux pane split on host ${host.id}: ${paneId} (${body.direction})`,
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendTmuxError(ctx, res, err, "split", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/search:
|
||||
* get:
|
||||
* summary: Search pane output across a host's tmux sessions
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: q
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Matching lines, capped per pane and across panes.
|
||||
*/
|
||||
router.get("/:hostId/search", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
const query = String(req.query.q || "").trim();
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: "Missing search query" });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await withHostConnection(ctx, host, async (conn) => {
|
||||
const allPanes = await listPanesRaw(conn);
|
||||
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
|
||||
// Flips to true whenever a limit was hit, so the UI can tell the user
|
||||
// the results are partial instead of silently truncating.
|
||||
let truncated = allPanes.length > MAX_SEARCH_PANES;
|
||||
const matches: Array<{
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
line: number;
|
||||
text: string;
|
||||
}> = [];
|
||||
|
||||
// Bounded concurrency; each search runs capture+grep remotely so only
|
||||
// matching lines travel back over the wire.
|
||||
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
|
||||
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (pane) => {
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
|
||||
),
|
||||
);
|
||||
const lines = output.split("\n").filter(Boolean);
|
||||
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
|
||||
for (const line of lines) {
|
||||
const sep = line.indexOf(":");
|
||||
if (sep === -1) continue;
|
||||
matches.push({
|
||||
paneId: pane.id,
|
||||
sessionName: pane.sessionName,
|
||||
windowIndex: pane.windowIndex,
|
||||
line: parseInt(line.slice(0, sep), 10) || 0,
|
||||
text: line.slice(sep + 1).slice(0, 500),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// grep exits non-zero when there are no matches -- not an error
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { matches, truncated };
|
||||
});
|
||||
res.json({
|
||||
query,
|
||||
matches: results.matches,
|
||||
truncated: results.truncated,
|
||||
searchedLines: SEARCH_HISTORY_LINES,
|
||||
maxPanes: MAX_SEARCH_PANES,
|
||||
});
|
||||
} catch (err) {
|
||||
sendTmuxError(ctx, res, err, "search", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/metrics:
|
||||
* get:
|
||||
* summary: Per-pane CPU, memory and GPU usage
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Metrics for every pane tmux reports.
|
||||
*/
|
||||
router.get("/:hostId/metrics", async (req, res) => {
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host) return;
|
||||
|
||||
try {
|
||||
const metrics = await withHostConnection(ctx, host, async (conn) => {
|
||||
const panes = await listPanesRaw(conn);
|
||||
if (panes.length === 0) return [];
|
||||
return collectPaneMetrics(conn, panes);
|
||||
});
|
||||
res.json({ panes: metrics });
|
||||
} catch (err) {
|
||||
sendTmuxError(ctx, res, err, "metrics", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/tmux-monitor/{hostId}/tags:
|
||||
* put:
|
||||
* summary: Replace the acting user's tags on a session
|
||||
* tags:
|
||||
* - Tmux Monitor
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tags saved.
|
||||
*/
|
||||
router.put("/:hostId/tags", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const host = await requireHost(ctx, req, res);
|
||||
if (!host || !userId) return;
|
||||
|
||||
const { sessionName, tags: rawTags } = req.body as {
|
||||
sessionName?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
if (!sessionName || typeof sessionName !== "string") {
|
||||
return res.status(400).json({ error: "Missing session name" });
|
||||
}
|
||||
if (!Array.isArray(rawTags) || rawTags.some((t) => typeof t !== "string")) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Tags must be an array of strings" });
|
||||
}
|
||||
const cleanTags = [
|
||||
...new Set(rawTags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
|
||||
].slice(0, 20);
|
||||
|
||||
try {
|
||||
await tags.replaceForUserHostSession(
|
||||
userId,
|
||||
host.id,
|
||||
sessionName,
|
||||
cleanTags,
|
||||
);
|
||||
res.json({ sessionName, tags: cleanTags });
|
||||
} catch (err) {
|
||||
ctx.log.error(
|
||||
`Failed to save tmux session tags for host ${host.id}`,
|
||||
err as Error,
|
||||
);
|
||||
res.status(500).json({ error: toErrorMessage(err) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
attachOrCreateTmuxSession,
|
||||
detectTmux,
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-commands.js";
|
||||
|
||||
export interface TmuxDetection {
|
||||
available: boolean;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the terminal (ssh-terminal's optional "tmux.sessions" consumer) asks
|
||||
* of this plugin. Clients and streams cross the service boundary as
|
||||
* `unknown`, since the SDK types services without an ssh2 dependency.
|
||||
*/
|
||||
export interface TmuxSessionsV1 {
|
||||
detect: (client: unknown) => Promise<TmuxDetection>;
|
||||
attachOrCreate: (
|
||||
stream: unknown,
|
||||
name?: string,
|
||||
newName?: string,
|
||||
) => Promise<void>;
|
||||
waitForSession: (client: unknown, name: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function createTmuxSessionsService(ctx: PluginContext): TmuxSessionsV1 {
|
||||
return {
|
||||
async detect(client) {
|
||||
const result = await detectTmux(client as Client);
|
||||
return {
|
||||
available: result.available,
|
||||
sessions: result.sessions.map((s) => s.name),
|
||||
};
|
||||
},
|
||||
|
||||
async attachOrCreate(stream, name, newName) {
|
||||
attachOrCreateTmuxSession(stream as ClientChannel, name, newName);
|
||||
},
|
||||
|
||||
async waitForSession(client, name) {
|
||||
const confirmed = await waitForTmuxSession(client as Client, name);
|
||||
if (!confirmed) {
|
||||
ctx.log.warn(
|
||||
`Timed out waiting for a new tmux session to appear: ${name}`,
|
||||
);
|
||||
return name;
|
||||
}
|
||||
return confirmed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* A user's tags on a tmux session, per host. Sessions are shared on the host
|
||||
* itself, but tags are per user: everyone who monitors the same host can tag
|
||||
* the same session differently.
|
||||
*
|
||||
* Adopted from core's tmux_session_tags, so the column names are the legacy
|
||||
* ones and the rename carries every existing row across. Core never indexed
|
||||
* it, so there is no legacy index to keep.
|
||||
*/
|
||||
export const sessionTags = adoptLegacyTable(
|
||||
"tmux_session_tags",
|
||||
defineTable("session_tags", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
sessionName: text().notNull(),
|
||||
tag: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [sessionTags];
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
|
||||
const TMUX_PATH_DIRS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/bin",
|
||||
"/usr/pkg/bin",
|
||||
];
|
||||
|
||||
export function withTmuxPath(command: string): string {
|
||||
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:"$PATH"; export PATH; ${command}`;
|
||||
return `/bin/sh -c ${shellEscape(script)}`;
|
||||
}
|
||||
|
||||
export function tmuxCommand(args: string): string {
|
||||
return withTmuxPath(`tmux -u ${args}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the remote host via a separate exec channel.
|
||||
* Returns stdout as a string. Does not pollute the interactive shell.
|
||||
*/
|
||||
export function execCommand(conn: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf-8");
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf-8");
|
||||
});
|
||||
stream.on("error", (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && stdout === "") {
|
||||
reject(
|
||||
new Error(stderr.trim() || `Command exited with code ${code}`),
|
||||
);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface TmuxDetectionResult {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether tmux is installed and list all existing sessions with details.
|
||||
*/
|
||||
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||
try {
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
} catch {
|
||||
return { available: false, sessions: [] };
|
||||
}
|
||||
|
||||
let sessions: TmuxSessionInfo[] = [];
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
|
||||
),
|
||||
);
|
||||
if (output) {
|
||||
sessions = output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [name, created, activity, windows, attached] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
windows: parseInt(windows, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// tmux server not running yet -- no sessions exist
|
||||
}
|
||||
|
||||
return { available: true, sessions };
|
||||
}
|
||||
|
||||
// tmux options applied on every attach/create:
|
||||
// - mouse on: enables mouse wheel / touch scrollback through tmux history
|
||||
// - history-limit: deep scrollback buffer on the remote host
|
||||
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
|
||||
// - mode-keys vi: use vi-style keys in copy mode
|
||||
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
|
||||
// adjust and press Enter to copy (or drag again)
|
||||
// - Enter: copy the (possibly adjusted) selection and exit copy mode
|
||||
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
|
||||
// know to press Enter to copy the selection
|
||||
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
|
||||
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
|
||||
const TMUX_OPTS =
|
||||
`set -gq mouse on` +
|
||||
` \\; set -gq history-limit 50000` +
|
||||
` \\; set -gq set-clipboard on` +
|
||||
` \\; set -gq aggressive-resize on` +
|
||||
` \\; set -gq mode-keys vi` +
|
||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
||||
` \\; set-hook -g pane-mode-changed` +
|
||||
` 'if -F "#{pane_in_mode}"` +
|
||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
||||
|
||||
/**
|
||||
* Wait for a tmux session to appear by polling via exec channel.
|
||||
* Returns the session name once found, or null on timeout.
|
||||
*/
|
||||
export async function waitForTmuxSession(
|
||||
conn: Client,
|
||||
sessionName: string,
|
||||
timeoutMs = 5000,
|
||||
intervalMs = 100,
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await execCommand(
|
||||
conn,
|
||||
tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`),
|
||||
);
|
||||
return sessionName;
|
||||
} catch {
|
||||
// session not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tmux attach or new-session command to the interactive shell stream.
|
||||
* Uses && exit so the shell only closes if tmux started successfully.
|
||||
*/
|
||||
export function attachOrCreateTmuxSession(
|
||||
stream: ClientChannel,
|
||||
existingSessionName?: string,
|
||||
newSessionName?: string,
|
||||
): void {
|
||||
let command: string;
|
||||
if (existingSessionName) {
|
||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
|
||||
} else {
|
||||
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
|
||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
|
||||
}
|
||||
stream.write(command);
|
||||
}
|
||||
|
||||
export function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Layers } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
|
||||
type PluginSettingsForm = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* The host editor's tmux monitor toggle. Writes into the form's
|
||||
* pluginSettings for this plugin, which the editor saves through the
|
||||
* plugin's host settings route after the host itself.
|
||||
*/
|
||||
export function HostTmuxMonitorSection({
|
||||
form,
|
||||
updateForm,
|
||||
}: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
|
||||
"tmux-monitor"
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
const enabled = settings.enableTmuxMonitor === true;
|
||||
|
||||
const setEnabled = (value: boolean) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
"tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("tmuxMonitor.title")}
|
||||
icon={<Layers className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableTmuxMonitor")}
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableTmuxMonitorDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/tmux"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FakeSwitch checked={enabled} onChange={setEnabled} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Interactive view of a tmux pane. Embeds the real SSH terminal (through the
|
||||
// terminal.view component ssh-terminal registers) and attaches to the
|
||||
// selected session via the native tmux_attach flow, so the pane is fully
|
||||
// usable: typing, mouse scrolling and tmux copy-mode all behave exactly like
|
||||
// a normal terminal tab.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
RotateCw,
|
||||
SquareSplitHorizontal,
|
||||
SquareSplitVertical,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import type { PluginHostRecord, TabHandle } from "@termix/plugin-sdk/frontend";
|
||||
import type { TmuxPaneMetrics } from "./api";
|
||||
import { formatMem } from "./format";
|
||||
import type { SelectedPane } from "./types";
|
||||
|
||||
interface PanePreviewProps {
|
||||
host: PluginHostRecord;
|
||||
pane: SelectedPane;
|
||||
metrics?: TmuxPaneMetrics;
|
||||
/** Imperative handle of the embedded terminal, used by the parent to nudge
|
||||
* a refit/redraw after layout-changing tmux actions. */
|
||||
terminalRef?: React.RefObject<TabHandle | null>;
|
||||
/** Split the window containing this pane ("h" = new pane to the right,
|
||||
* "v" = below — tmux -h/-v semantics). */
|
||||
onSplit: (direction: "h" | "v") => void;
|
||||
/** Ask for confirmation and kill this pane. */
|
||||
onKillPane: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function newInstanceId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function PanePreview({
|
||||
host,
|
||||
pane,
|
||||
metrics,
|
||||
terminalRef,
|
||||
onSplit,
|
||||
onKillPane,
|
||||
onClose,
|
||||
}: PanePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
// One PTY per mount; the parent remounts this component (via key) when the
|
||||
// host or session changes. Pane switches within a session go through the
|
||||
// focus endpoint instead, so the connection is reused.
|
||||
const instanceIdRef = useRef<string>(newInstanceId());
|
||||
// Bumping this remounts the embedded terminal with a fresh PTY — the rescue
|
||||
// hatch when the attached client's rendering gets out of sync.
|
||||
const [attachNonce, setAttachNonce] = useState(0);
|
||||
|
||||
function reattach() {
|
||||
instanceIdRef.current = newInstanceId();
|
||||
setAttachNonce((n) => n + 1);
|
||||
}
|
||||
|
||||
// The host's own authType travels in the spread, so a Tailscale host still
|
||||
// gets the Tailscale-aware PTY path. The SSH port may be non-standard.
|
||||
const hostRecord = host as unknown as Record<string, unknown>;
|
||||
const resolvedPort = hostRecord.sshPort ?? hostRecord.port;
|
||||
const terminalHostConfig = {
|
||||
...hostRecord,
|
||||
port: resolvedPort,
|
||||
sshPort: resolvedPort,
|
||||
instanceId: instanceIdRef.current,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{pane.sessionName} · {pane.paneId}
|
||||
</span>
|
||||
{metrics && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="size-3" />
|
||||
{metrics.cpuPercent.toFixed(1)}%
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="size-3" />
|
||||
{formatMem(metrics.memRssKb)}
|
||||
</span>
|
||||
{metrics.gpuMemMb > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="size-3" />
|
||||
{metrics.gpuMemMb} MB GPU
|
||||
</span>
|
||||
)}
|
||||
{metrics.topCommand && (
|
||||
<span className="truncate">
|
||||
{metrics.topCommand} ({metrics.processCount})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.splitRight")}
|
||||
aria-label={t("tmuxMonitor.splitRight")}
|
||||
onClick={() => onSplit("h")}
|
||||
>
|
||||
<SquareSplitHorizontal className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.splitDown")}
|
||||
aria-label={t("tmuxMonitor.splitDown")}
|
||||
onClick={() => onSplit("v")}
|
||||
>
|
||||
<SquareSplitVertical className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title={t("tmuxMonitor.killPane")}
|
||||
aria-label={t("tmuxMonitor.killPane")}
|
||||
onClick={onKillPane}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
<div className="h-3.5 border-l border-border" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.reattach")}
|
||||
aria-label={t("tmuxMonitor.reattach")}
|
||||
onClick={reattach}
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.closePreview")}
|
||||
aria-label={t("tmuxMonitor.closePreview")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PluginComponent
|
||||
id="terminal.view"
|
||||
key={attachNonce}
|
||||
handleRef={terminalRef}
|
||||
hostConfig={terminalHostConfig}
|
||||
isVisible={true}
|
||||
title={pane.sessionName}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
tmuxAttachSession={pane.sessionName}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { X } from "lucide-react";
|
||||
import type { TmuxSearchMatch, TmuxSearchResult } from "./api";
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: TmuxSearchMatch[];
|
||||
searching: boolean;
|
||||
/** The query that produced these results, used to highlight matches. */
|
||||
query: string;
|
||||
/** Search-limit info; renders a "partial results" note when truncated. */
|
||||
limits: Pick<
|
||||
TmuxSearchResult,
|
||||
"truncated" | "searchedLines" | "maxPanes"
|
||||
> | null;
|
||||
onSelect: (match: TmuxSearchMatch) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const index = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (index === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, index)}
|
||||
<mark className="bg-accent-brand/30 px-0 text-foreground">
|
||||
{text.slice(index, index + query.length)}
|
||||
</mark>
|
||||
{text.slice(index + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
results,
|
||||
searching,
|
||||
query,
|
||||
limits,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: SearchResultsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="max-h-56 overflow-y-auto border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{searching
|
||||
? t("common.loading")
|
||||
: t("tmuxMonitor.searchResults", { count: results.length })}
|
||||
{!searching && limits?.truncated && (
|
||||
<span className="ml-2 text-muted-foreground/70">
|
||||
{t("tmuxMonitor.searchTruncated", {
|
||||
lines: limits.searchedLines,
|
||||
panes: limits.maxPanes,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.closeSearchResults")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{results.map((match, i) => (
|
||||
<div
|
||||
key={`${match.paneId}-${match.line}-${i}`}
|
||||
className="flex cursor-pointer items-baseline gap-2 px-3 py-1 text-xs hover:bg-muted/40"
|
||||
onClick={() => onSelect(match)}
|
||||
>
|
||||
<span className="shrink-0 font-medium text-primary">
|
||||
{match.sessionName} · {match.paneId}
|
||||
</span>
|
||||
<span className="truncate font-mono text-muted-foreground">
|
||||
{highlightMatch(match.text, query)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// Session/window/pane tree styled after the VSCode "tmux manager" view.
|
||||
// Each session is a single row: status dot, name, inline tag badges, and a
|
||||
// right-aligned meta label (relative time · cpu · mem) that is overlaid by
|
||||
// the hover actions (attach, new window, …-menu). The overlay uses a grid
|
||||
// stack with opacity so rows never shift and the actions stay reachable via
|
||||
// keyboard focus.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
AppWindow,
|
||||
BarChart2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
SquareSplitHorizontal,
|
||||
SquareSplitVertical,
|
||||
SquareTerminal,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { TmuxPaneMetrics, TmuxSessionOverview } from "./api";
|
||||
import { formatMem, formatRelativeTime } from "./format";
|
||||
import type { SelectedPane } from "./types";
|
||||
|
||||
export interface SessionMetricsAgg {
|
||||
cpu: number;
|
||||
memKb: number;
|
||||
gpuMb: number;
|
||||
}
|
||||
|
||||
const MAX_INLINE_TAGS = 2;
|
||||
|
||||
interface SessionTreeProps {
|
||||
sessions: TmuxSessionOverview[];
|
||||
expandedSessions: Set<string>;
|
||||
onToggleSession: (name: string) => void;
|
||||
selectedPaneId: string | null;
|
||||
onSelectPane: (pane: SelectedPane) => void;
|
||||
metricsByPane: Map<string, TmuxPaneMetrics>;
|
||||
metricsBySession: Map<string, SessionMetricsAgg>;
|
||||
/** Open the tag editor for a session (… menu). */
|
||||
onEditTags: (sessionName: string) => void;
|
||||
/** Open a terminal attached to the session (the ▷ hover action). */
|
||||
onAttachSession: (sessionName: string) => void;
|
||||
/** Create a new window in the session (the + hover action). */
|
||||
onNewWindow: (sessionName: string) => void;
|
||||
/** Open the rename dialog for a session (… menu). */
|
||||
onRenameSession: (sessionName: string) => void;
|
||||
/** Open the kill confirmation for a session (… menu). */
|
||||
onKillSession: (sessionName: string) => void;
|
||||
/** Open the kill confirmation for a single pane (hover ✕ on pane rows). */
|
||||
onKillPane: (paneId: string) => void;
|
||||
/** Split the window containing a pane (hover actions on pane rows). */
|
||||
onSplitPane: (paneId: string, direction: "h" | "v") => void;
|
||||
/** Open the kill confirmation for a window (hover ✕ on window rows). */
|
||||
onKillWindow: (sessionName: string, windowIndex: number) => void;
|
||||
/** Narrow tree panel: the inline time/cpu/mem label is hidden (the hover
|
||||
* tooltip still carries the full status). */
|
||||
compact: boolean;
|
||||
/** Timestamp used to render relative times; bumped periodically by the
|
||||
* parent so "Xm ago" labels do not go stale. */
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
sessions,
|
||||
expandedSessions,
|
||||
onToggleSession,
|
||||
selectedPaneId,
|
||||
onSelectPane,
|
||||
metricsByPane,
|
||||
metricsBySession,
|
||||
onEditTags,
|
||||
onAttachSession,
|
||||
onNewWindow,
|
||||
onRenameSession,
|
||||
onKillSession,
|
||||
onKillPane,
|
||||
onSplitPane,
|
||||
onKillWindow,
|
||||
compact,
|
||||
now,
|
||||
}: SessionTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
// Collapsed windows, keyed "session windowIndex". Windows default to
|
||||
// expanded (panes visible) like the VSCode tmux manager.
|
||||
const [collapsedWindows, setCollapsedWindows] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
function windowKey(sessionName: string, windowIndex: number): string {
|
||||
return `${sessionName} ${windowIndex}`;
|
||||
}
|
||||
|
||||
function toggleWindow(sessionName: string, windowIndex: number) {
|
||||
setCollapsedWindows((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = windowKey(sessionName, windowIndex);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function metaLabel(session: TmuxSessionOverview): string {
|
||||
const time = formatRelativeTime(session.lastActivity, now, t);
|
||||
const agg = metricsBySession.get(session.name);
|
||||
if (!agg) return time;
|
||||
return `${time} · ${agg.cpu.toFixed(0)}% · ${formatMem(agg.memKb)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{sessions.map((session) => {
|
||||
const expanded = expandedSessions.has(session.name);
|
||||
const agg = metricsBySession.get(session.name);
|
||||
const paneCount = session.windows.reduce(
|
||||
(sum, w) => sum + w.panes.length,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div key={session.name} className="mb-0.5">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
|
||||
onClick={() => onToggleSession(session.name)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
title={
|
||||
session.attachedClients > 0
|
||||
? t("tmuxMonitor.attached")
|
||||
: t("tmuxMonitor.detached")
|
||||
}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{session.name}
|
||||
</span>
|
||||
{session.tags.slice(0, MAX_INLINE_TAGS).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="h-4 shrink-0 px-1.5 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{session.tags.length > MAX_INLINE_TAGS && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 shrink-0 px-1.5 text-[10px]"
|
||||
title={session.tags.join(", ")}
|
||||
>
|
||||
+{session.tags.length - MAX_INLINE_TAGS}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Meta label and hover actions stacked in one grid cell */}
|
||||
<span className="ml-auto grid shrink-0">
|
||||
{!compact && (
|
||||
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-xs text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{metaLabel(session)}
|
||||
</span>
|
||||
)}
|
||||
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1.5 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.attachSessionTooltip", {
|
||||
session: session.name,
|
||||
})}
|
||||
aria-label={t("tmuxMonitor.attachSessionTooltip", {
|
||||
session: session.name,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAttachSession(session.name);
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.newWindow")}
|
||||
aria-label={t("tmuxMonitor.newWindow")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewWindow(session.name);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
|
||||
title={t("tmuxMonitor.sessionStats")}
|
||||
aria-label={t("tmuxMonitor.sessionStats")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<BarChart2 className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-56 rounded-none border border-border p-0 shadow-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-full px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<span className="text-xs font-semibold">
|
||||
{session.name}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{session.attachedClients > 0
|
||||
? t("tmuxMonitor.attached")
|
||||
: t("tmuxMonitor.detached")}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px]">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("tmuxMonitor.statusActivity")}
|
||||
</dt>
|
||||
<dd className="text-right">
|
||||
{formatRelativeTime(session.lastActivity, now, t)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("tmuxMonitor.statusWindows")}
|
||||
</dt>
|
||||
<dd className="text-right">
|
||||
{session.windows.length} / {paneCount}{" "}
|
||||
{t("tmuxMonitor.statusPanes")}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">CPU</dt>
|
||||
<dd className="text-right">
|
||||
{agg ? `${agg.cpu.toFixed(1)}%` : "—"}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">RAM</dt>
|
||||
<dd className="text-right">
|
||||
{agg ? formatMem(agg.memKb) : "—"}
|
||||
</dd>
|
||||
{agg && agg.gpuMb > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">GPU</dt>
|
||||
<dd className="text-right">{agg.gpuMb} MB</dd>
|
||||
</>
|
||||
)}
|
||||
{session.tags.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("tmuxMonitor.statusTags")}
|
||||
</dt>
|
||||
<dd className="truncate text-right">
|
||||
{session.tags.join(", ")}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
|
||||
title={t("tmuxMonitor.moreActions")}
|
||||
aria-label={t("tmuxMonitor.moreActions")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="min-w-36"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="whitespace-nowrap"
|
||||
onSelect={() =>
|
||||
setTimeout(() => onRenameSession(session.name), 0)
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{t("tmuxMonitor.rename")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="whitespace-nowrap"
|
||||
onSelect={() =>
|
||||
setTimeout(() => onEditTags(session.name), 0)
|
||||
}
|
||||
>
|
||||
<Tag className="size-3.5" />
|
||||
{t("tmuxMonitor.editTags")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="whitespace-nowrap"
|
||||
onSelect={() =>
|
||||
setTimeout(() => onKillSession(session.name), 0)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("tmuxMonitor.kill")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
session.windows.map((win) => {
|
||||
const winCollapsed = collapsedWindows.has(
|
||||
windowKey(session.name, win.index),
|
||||
);
|
||||
return (
|
||||
<div key={win.index} className="ml-4">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/40"
|
||||
onClick={() => toggleWindow(session.name, win.index)}
|
||||
>
|
||||
{winCollapsed ? (
|
||||
<ChevronRight className="size-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
)}
|
||||
<AppWindow className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{win.index}: {win.name}
|
||||
</span>
|
||||
<button
|
||||
className="-m-1 ml-auto shrink-0 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
title={t("tmuxMonitor.killWindow")}
|
||||
aria-label={t("tmuxMonitor.killWindow")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKillWindow(session.name, win.index);
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{!winCollapsed &&
|
||||
win.panes.map((pane) => {
|
||||
const m = metricsByPane.get(pane.id);
|
||||
const isSelected = selectedPaneId === pane.id;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
className={`group ml-5 flex cursor-pointer items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/40 ${isSelected ? "bg-accent-brand/10" : ""}`}
|
||||
onClick={() =>
|
||||
onSelectPane({
|
||||
paneId: pane.id,
|
||||
sessionName: session.name,
|
||||
windowIndex: win.index,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquareTerminal className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{pane.index}: {pane.command}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-muted-foreground/70"
|
||||
title={pane.path}
|
||||
>
|
||||
{pane.path}
|
||||
</span>
|
||||
{/* Same overlay stack as the session rows: cpu
|
||||
label and hover actions share one grid cell so the
|
||||
buttons never widen the row (they would get
|
||||
clipped on a narrow panel). */}
|
||||
<span className="ml-auto grid shrink-0">
|
||||
{m && m.cpuPercent > 0 && (
|
||||
<span className="pointer-events-none col-start-1 row-start-1 justify-self-end self-center text-[10px] text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{m.cpuPercent.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.splitRight")}
|
||||
aria-label={t("tmuxMonitor.splitRight")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplitPane(pane.id, "h");
|
||||
}}
|
||||
>
|
||||
<SquareSplitHorizontal className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("tmuxMonitor.splitDown")}
|
||||
aria-label={t("tmuxMonitor.splitDown")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplitPane(pane.id, "v");
|
||||
}}
|
||||
>
|
||||
<SquareSplitVertical className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
className="-m-1 p-1 text-muted-foreground hover:text-destructive"
|
||||
title={t("tmuxMonitor.killPane")}
|
||||
aria-label={t("tmuxMonitor.killPane")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKillPane(pane.id);
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
import type { StandaloneViewProps } from "@termix/plugin-sdk/frontend";
|
||||
import { TmuxMonitor } from "./TmuxMonitor";
|
||||
|
||||
/** `?view=tmux_monitor` full-screen links. */
|
||||
function TmuxMonitorApp({ hostId }: StandaloneViewProps) {
|
||||
const parsed = hostId ? parseInt(hostId, 10) : NaN;
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<TmuxMonitor
|
||||
initialHostId={Number.isFinite(parsed) ? parsed : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TmuxMonitorApp;
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
let client: PluginApiClient | null = null;
|
||||
|
||||
/** Set from activate with app.api, cleared on deactivate. */
|
||||
export function setTmuxMonitorApi(api: PluginApiClient | null): void {
|
||||
client = api;
|
||||
}
|
||||
|
||||
function api(): PluginApiClient {
|
||||
if (!client) throw new Error("The Tmux Monitor plugin is not active");
|
||||
return client;
|
||||
}
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
index: number;
|
||||
pid: number;
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
command: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
index: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
panes: TmuxPane[];
|
||||
}
|
||||
|
||||
export interface TmuxSessionOverview {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
attachedClients: number;
|
||||
windows: TmuxWindow[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TmuxOverview {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionOverview[];
|
||||
}
|
||||
|
||||
export interface TmuxSearchMatch {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
line: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TmuxPaneMetrics {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
pid: number;
|
||||
processCount: number;
|
||||
cpuPercent: number;
|
||||
memRssKb: number;
|
||||
gpuMemMb: number;
|
||||
topCommand: string | null;
|
||||
}
|
||||
|
||||
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
|
||||
const response = await api().get<TmuxOverview>(`/${hostId}/overview`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Select a pane's window+pane on the server so the attached terminal
|
||||
* (and any other attached client) switches to it. */
|
||||
export async function focusTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/focus`, { paneId });
|
||||
}
|
||||
|
||||
/** Create a new detached session on the host. Starts the tmux server if
|
||||
* none is running yet. */
|
||||
export async function createTmuxSession(
|
||||
hostId: number,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/sessions`, { name });
|
||||
}
|
||||
|
||||
/** Create a new window in an existing session. */
|
||||
export async function createTmuxWindow(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/windows`, { sessionName });
|
||||
}
|
||||
|
||||
/** Rename a session. Saved tags follow the session to its new name. */
|
||||
export async function renameTmuxSession(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/rename`, { sessionName, newName });
|
||||
}
|
||||
|
||||
/** Kill a session (terminates all of its windows and processes). */
|
||||
export async function killTmuxSession(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/kill`, { sessionName });
|
||||
}
|
||||
|
||||
/** Kill a window and every pane in it. */
|
||||
export async function killTmuxWindow(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
windowIndex: number,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/kill-window`, { sessionName, windowIndex });
|
||||
}
|
||||
|
||||
/** Kill a single pane (last pane of a window closes the window). */
|
||||
export async function killTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/kill-pane`, { paneId });
|
||||
}
|
||||
|
||||
/** Split the window containing a pane: "h" adds a pane to the right,
|
||||
* "v" below (tmux -h/-v semantics). */
|
||||
export async function splitTmuxPane(
|
||||
hostId: number,
|
||||
paneId: string,
|
||||
direction: "h" | "v",
|
||||
): Promise<void> {
|
||||
await api().post(`/${hostId}/split`, { paneId, direction });
|
||||
}
|
||||
|
||||
export interface TmuxSearchResult {
|
||||
matches: TmuxSearchMatch[];
|
||||
/** True when a search limit was hit and the results are partial. */
|
||||
truncated: boolean;
|
||||
searchedLines: number;
|
||||
maxPanes: number;
|
||||
}
|
||||
|
||||
export async function searchTmux(
|
||||
hostId: number,
|
||||
query: string,
|
||||
): Promise<TmuxSearchResult> {
|
||||
const response = await api().get<Partial<TmuxSearchResult>>(
|
||||
`/${hostId}/search`,
|
||||
{ params: { q: query } },
|
||||
);
|
||||
return {
|
||||
matches: response.data.matches ?? [],
|
||||
truncated: response.data.truncated ?? false,
|
||||
searchedLines: response.data.searchedLines ?? 0,
|
||||
maxPanes: response.data.maxPanes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTmuxMetrics(
|
||||
hostId: number,
|
||||
): Promise<TmuxPaneMetrics[]> {
|
||||
const response = await api().get<{ panes?: TmuxPaneMetrics[] }>(
|
||||
`/${hostId}/metrics`,
|
||||
);
|
||||
return response.data.panes ?? [];
|
||||
}
|
||||
|
||||
export async function setTmuxSessionTags(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<string[]> {
|
||||
const response = await api().put<{ tags?: string[] }>(`/${hostId}/tags`, {
|
||||
sessionName,
|
||||
tags,
|
||||
});
|
||||
return response.data.tags ?? [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function formatRelativeTime(
|
||||
unixSeconds: number,
|
||||
nowMs: number,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
if (!unixSeconds) return "";
|
||||
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
|
||||
if (diff < 60) return t("tmuxMonitor.timeJustNow");
|
||||
if (diff < 3600)
|
||||
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
|
||||
if (diff < 86400)
|
||||
return t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) });
|
||||
return t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
export function formatMem(kb: number): string {
|
||||
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (kb >= 1024) return `${(kb / 1024).toFixed(0)} MB`;
|
||||
return `${kb} KB`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** Whether the monitor is enabled for this host, as the host payload's
|
||||
* pluginSettings carries it. */
|
||||
export function tmuxMonitorEnabled(
|
||||
host: PluginHostRecord | null | undefined,
|
||||
): boolean {
|
||||
const settings = (
|
||||
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.["tmux-monitor"];
|
||||
return settings?.enableTmuxMonitor === true;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Layers } from "lucide-react";
|
||||
import type {
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { setTmuxMonitorApi } from "./api";
|
||||
import { tmuxMonitorEnabled } from "./host-tmux-monitor";
|
||||
import { HostTmuxMonitorSection } from "./HostTmuxMonitorSection";
|
||||
import { TmuxMonitor } from "./TmuxMonitor";
|
||||
import TmuxMonitorApp from "./TmuxMonitorApp";
|
||||
|
||||
const VIEW_ID = "tmux_monitor";
|
||||
|
||||
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
|
||||
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
|
||||
return <TmuxMonitor initialHostId={hostId} isVisible={isVisible} />;
|
||||
}
|
||||
|
||||
function TmuxMonitorStandalone(props: StandaloneViewProps) {
|
||||
return <TmuxMonitorApp {...props} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setTmuxMonitorApi(app.api);
|
||||
app.onDispose(() => setTmuxMonitorApi(null));
|
||||
|
||||
// A single cross-host browser, like the old core tab: opening it from a
|
||||
// host row just points the already-open (or newly opened) tab at that host.
|
||||
app.registerTab(VIEW_ID, TmuxMonitorTab, {
|
||||
icon: Layers,
|
||||
titleKey: "nav.tmuxMonitor",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
persistent: true,
|
||||
activityTypes: ["tmux_monitor"],
|
||||
standalone: TmuxMonitorStandalone,
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "tmux_monitor",
|
||||
titleKey: "nav.tmuxMonitor",
|
||||
icon: Layers,
|
||||
kind: "open",
|
||||
order: 70,
|
||||
copyUrlView: VIEW_ID,
|
||||
when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host),
|
||||
run: (host, shell) =>
|
||||
shell.openSingletonTab(VIEW_ID, { data: { hostId: Number(host.id) } }),
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "tmux-monitor",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabTmuxMonitor",
|
||||
icon: Layers,
|
||||
order: 45,
|
||||
component: HostTmuxMonitorSection,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SelectedPane {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
|
||||
|
||||
type AdaptivePollResult = boolean | void;
|
||||
|
||||
interface AdaptivePollingPolicy {
|
||||
minIntervalMs: number;
|
||||
maxIntervalMs: number;
|
||||
stablePollsPerStep?: number;
|
||||
jitterRatio?: number;
|
||||
maxRequestDutyCycle?: number;
|
||||
}
|
||||
|
||||
export function useAdaptivePolling(
|
||||
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
|
||||
policy: AdaptivePollingPolicy,
|
||||
enabled = true,
|
||||
options: {
|
||||
runImmediately?: boolean;
|
||||
onError?: (error: unknown) => void;
|
||||
} = {},
|
||||
): void {
|
||||
const pollRef = useRef(poll);
|
||||
const errorRef = useRef(options.onError);
|
||||
pollRef.current = poll;
|
||||
errorRef.current = options.onError;
|
||||
const {
|
||||
minIntervalMs,
|
||||
maxIntervalMs,
|
||||
stablePollsPerStep,
|
||||
jitterRatio,
|
||||
maxRequestDutyCycle,
|
||||
} = policy;
|
||||
const runImmediately = options.runImmediately;
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
runAdaptivePolling(
|
||||
() => pollRef.current(),
|
||||
{
|
||||
minIntervalMs,
|
||||
maxIntervalMs,
|
||||
stablePollsPerStep,
|
||||
jitterRatio,
|
||||
maxRequestDutyCycle,
|
||||
},
|
||||
{
|
||||
enabled: () => enabled,
|
||||
runImmediately,
|
||||
onError: (error) => errorRef.current?.(error),
|
||||
},
|
||||
),
|
||||
[
|
||||
enabled,
|
||||
jitterRatio,
|
||||
maxIntervalMs,
|
||||
minIntervalMs,
|
||||
runImmediately,
|
||||
stablePollsPerStep,
|
||||
maxRequestDutyCycle,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user