import express from "express"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; import { eq, and } from "drizzle-orm"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { AuthManager } from "../utils/auth-manager.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { tmuxSessionTags, users } from "../database/db/schema.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { sshLogger } from "../utils/logger.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { resolveHostById, checkHostAccess } from "./host-resolver.js"; import { createJumpHostChain } from "./jump-host-chain.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { withConnection } from "./ssh-connection-pool.js"; import { resolveSshConnectConfigHost } from "./ssh-dns.js"; import { execCommand, tmuxCommand } from "./tmux-helper.js"; import { SEP, parseSessions, parseWindows, parsePanes, parsePsOutput, parseGpuOutput, buildPaneMetrics, attachPanesToWindows, shellEscape, type RawPane, type TmuxSessionSummary, type TmuxWindow, type PaneMetrics, } from "./tmux-monitor-helpers.js"; import type { SSHHost, AuthenticatedRequest } from "../../types/index.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; interface TmuxSessionOverview extends TmuxSessionSummary { windows: TmuxWindow[]; tags: string[]; } // --------------------------------------------------------------------------- // SSH connection (lean variant of the per-module pattern used by server-stats // and docker; jump hosts and SOCKS5 reuse the shared helpers) // --------------------------------------------------------------------------- async function buildSshConfig(host: SSHHost): Promise { const base: ConnectConfig = { host: (host.ip || "").replace(/^\[|\]$/g, ""), port: host.port, username: host.username, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( host.id, host.ip, host.port, null, host.userId || "", false, ), algorithms: SSH_ALGORITHMS, } as ConnectConfig; if (host.authType === "password") { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); } base.password = host.password; } else if (host.authType === "key") { if (!host.key) { throw new Error(`No valid SSH key available for host ${host.ip}`); } (base as Record).privateKey = preparePrivateKeyForSSH2( host.key, host.keyPassword, ); if (host.keyPassword) { (base as Record).passphrase = host.keyPassword; } } else if (host.authType === "none") { // no credentials needed } else if (host.authType === "vault") { // cert auth setup happens in connectToHost (needs client instance) } else if (host.authType === "agent") { const result = await applyAgentAuth( base as Record, host.terminalConfig as unknown as Record | undefined, ); if ("error" in result) { throw new Error(result.error); } } else { // opkssh and other interactive flows are not supported by this module throw new Error( `Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`, ); } return base; } export function connectToHost(host: SSHHost): () => Promise { return async () => { const config = await buildSshConfig(host); const client = new Client(); if (host.authType === "vault") { const { setupVaultSshSignerAuth } = await import("./vault-ssh-connect.js"); await setupVaultSshSignerAuth(config, client, host); } const proxyConfig: SOCKS5Config | null = host.useSocks5 && (host.socks5Host || (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) ? { useSocks5: host.useSocks5, socks5Host: host.socks5Host, socks5Port: host.socks5Port, socks5Username: host.socks5Username, socks5Password: host.socks5Password, socks5ProxyChain: host.socks5ProxyChain, } : null; let jumpClient: Client | null = null; if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { jumpClient = await createJumpHostChain( host.jumpHosts, host.userId, proxyConfig, ); if (!jumpClient) { throw new Error("Failed to establish jump host chain"); } } else if (proxyConfig) { const proxySocket = await createSocks5Connection( host.ip, host.port, proxyConfig, ); if (proxySocket) { config.sock = proxySocket; } } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { client.end(); jumpClient?.end(); reject(new Error("SSH connection timeout")); }, 30000); client.on("ready", () => { clearTimeout(timeout); resolve(client); }); client.on("error", (err) => { clearTimeout(timeout); jumpClient?.end(); reject(err); }); client.on( "keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => { finish( prompts.map((p) => /password/i.test(p.prompt) ? host.password || "" : "", ), ); }, ); if (jumpClient) { jumpClient.forwardOut( "127.0.0.1", 0, host.ip, host.port, (err, stream) => { if (err) { clearTimeout(timeout); jumpClient!.end(); reject( new Error( "Failed to forward through jump host: " + err.message, ), ); return; } config.sock = stream; client.connect(config); }, ); } else if (config.sock) { client.connect(config); } else { resolveSshConnectConfigHost(config) .then(() => { client.connect(config); }) .catch((error) => { clearTimeout(timeout); reject(error); }); } }); }; } function getPoolKey(host: SSHHost): string { const socks5Key = host.useSocks5 ? `:socks5:${host.socks5Host}:${host.socks5Port}` : ""; return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; } async function withHostConnection( host: SSHHost, fn: (client: Client) => Promise, ): Promise { return withConnection(getPoolKey(host), connectToHost(host), fn); } // --------------------------------------------------------------------------- // tmux queries // --------------------------------------------------------------------------- async function tmuxAvailable(conn: Client): Promise { try { await execCommand(conn, tmuxCommand("-V")); return true; } catch { return false; } } async function runTmuxList(conn: Client, command: string): Promise { 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 { return parsePanes(await runTmuxList(conn, listPanesCmd())); } async function fetchSessionTags( userId: string, hostId: number, ): Promise> { const rows = await getDb() .select() .from(tmuxSessionTags) .where( and( eq(tmuxSessionTags.userId, userId), eq(tmuxSessionTags.hostId, hostId), ), ); const bySession = new Map(); for (const row of rows) { if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []); bySession.get(row.sessionName)!.push(row.tag); } return bySession; } async function collectPaneMetrics( conn: Client, panes: RawPane[], ): Promise { let psOutput = ""; try { psOutput = await execCommand( conn, "ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null", ); } catch { return []; } // GPU memory per pid (best effort; nvidia-smi may not exist) 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), ); } // --------------------------------------------------------------------------- // Express app // --------------------------------------------------------------------------- const app = express(); const authManager = AuthManager.getInstance(); app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); app.use(authManager.createAuthMiddleware()); /** * Resolve the host for a request and verify the user can access it. * Sends the error response and returns null when access is denied. */ async function requireHost( req: express.Request, res: express.Response, permission: "read" | "execute" = "read", ): Promise { const userId = (req as unknown as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.hostId), 10); if (isNaN(hostId)) { res.status(400).json({ error: "Invalid host ID" }); return null; } if (!SimpleDBOps.isUserDataUnlocked(userId)) { res.status(401).json({ error: "User data is locked" }); return null; } let host: SSHHost | null = null; try { host = await resolveHostById(hostId, userId); } catch (err) { sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err); } if (!host) { res.status(404).json({ error: "Host not found" }); return null; } const hasAccess = await checkHostAccess( hostId, userId, host.userId || userId, permission, ); if (!hasAccess) { res.status(403).json({ error: "Access denied" }); return null; } // The monitor is opt-in per host (same pattern as enableDocker in // docker.ts): hiding the UI is not enough, the API must refuse too. if (!host.enableTmuxMonitor) { res .status(403) .json({ error: "Tmux Monitor is not enabled for this host" }); return null; } return host; } function toErrorMessage(err: unknown): string { return err instanceof Error ? err.message : "Unknown error"; } // Destructive tmux actions terminate processes on the remote host, so they // land in the audit log like other host-level mutations (see host.ts). async function auditTmuxAction( req: express.Request, host: SSHHost, action: string, resourceName: string, details?: Record, ): Promise { const userId = (req as unknown as AuthenticatedRequest).userId; const { ipAddress, userAgent } = getRequestMeta(req); let username = userId; try { const actor = await getDb() .select({ username: users.username }) .from(users) .where(eq(users.id, userId)) .limit(1); username = actor[0]?.username ?? userId; } catch { // fall back to the raw user id } await logAudit({ userId, username, action, resourceType: "host", resourceId: String(host.id), resourceName, details: details ? JSON.stringify(details) : undefined, ipAddress, userAgent, success: true, }); } // Typed error codes so the frontend can render a helpful state instead of a // raw 500 (same pattern as SESSION_EXPIRED handling in main-axios). type TmuxErrorCode = | "TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR"; function classifyTmuxError(err: unknown): TmuxErrorCode { const msg = err instanceof Error ? err.message : ""; 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( 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); sshLogger.error(`tmux ${context} failed for host ${hostId}`, err); res.status(status).json({ error, code }); } app.get("/tmux_monitor/:hostId/overview", async (req, res) => { const userId = (req as unknown as AuthenticatedRequest).userId; const host = await requireHost(req, res); if (!host) return; try { const result = await withHostConnection(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 tags = await fetchSessionTags(userId, host.id); const full: TmuxSessionOverview[] = sessions.map((s) => ({ ...s, windows: windows.get(s.name) || [], tags: tags.get(s.name) || [], })); return { available: true, sessions: full }; }); res.json(result); } catch (err) { sendTmuxError(res, err, "overview", host.id); } }); // Focus a pane: select its window and pane on the server so every attached // client (including the monitor's embedded terminal) switches to it. app.post("/tmux_monitor/:hostId/focus", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => // A pane id is a valid window target: tmux resolves it to the window // containing the pane. execCommand( conn, tmuxCommand( `select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`, ), ), ); res.json({ ok: true }); } catch (err) { sendTmuxError(res, err, "focus", host.id); } }); // Create a detached session. Starts the tmux server if none is running. app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)), ); sshLogger.info("tmux session created", { operation: "tmux_session_create", hostId: host.id, sessionName: name, }); 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(res, err, "create session", host.id); } }); // Create a window in an existing session. The session name comes from tmux's // own listing, so it is only checked for characters that would change the // target's meaning (":" and "." are window/pane separators in tmux targets); // "=" prefixes the target for an exact-name match. app.post("/tmux_monitor/:hostId/windows", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand( conn, tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`), ), ); sshLogger.info("tmux window created", { operation: "tmux_window_create", hostId: host.id, sessionName, }); 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(res, err, "create window", host.id); } }); // Rename a session. Saved tags follow the session to its new name (for every // user — the session itself is shared on the host). app.post("/tmux_monitor/:hostId/rename", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand( conn, tmuxCommand( `rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`, ), ), ); await getDb() .update(tmuxSessionTags) .set({ sessionName: newName }) .where( and( eq(tmuxSessionTags.hostId, host.id), eq(tmuxSessionTags.sessionName, sessionName), ), ); await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); sshLogger.info("tmux session renamed", { operation: "tmux_session_rename", hostId: host.id, sessionName, newName, }); await auditTmuxAction(req, host, "tmux_session_rename", sessionName, { newName, }); 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(res, err, "rename session", host.id); } }); // Kill a session and drop its saved tags. app.post("/tmux_monitor/:hostId/kill", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand( conn, tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`), ), ); await getDb() .delete(tmuxSessionTags) .where( and( eq(tmuxSessionTags.hostId, host.id), eq(tmuxSessionTags.sessionName, sessionName), ), ); await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); sshLogger.info("tmux session killed", { operation: "tmux_session_kill", hostId: host.id, sessionName, }); await auditTmuxAction(req, host, "tmux_session_kill", sessionName); 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(res, err, "kill session", host.id); } }); // Kill a window (and every pane in it). Killing the last window of a session // ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand( conn, tmuxCommand( `kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`, ), ), ); sshLogger.info("tmux window killed", { operation: "tmux_window_kill", hostId: host.id, sessionName, windowIndex, }); await auditTmuxAction(req, host, "tmux_window_kill", sessionName, { windowIndex, }); 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(res, err, "kill window", host.id); } }); // Kill a single pane. Killing the last pane of a window closes the window, // and the last window of a session ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)), ); sshLogger.info("tmux pane killed", { operation: "tmux_pane_kill", hostId: host.id, paneId, }); await auditTmuxAction(req, host, "tmux_pane_kill", paneId); 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(res, err, "kill pane", host.id); } }); // Split the window containing a pane. "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. app.post("/tmux_monitor/:hostId/split", async (req, res) => { const host = await requireHost(req, res, "execute"); 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(host, (conn) => execCommand( conn, tmuxCommand( `split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`, ), ), ); sshLogger.info("tmux pane split", { operation: "tmux_pane_split", hostId: host.id, paneId, direction: body.direction, }); res.json({ ok: true }); } catch (err) { sendTmuxError(res, err, "split", host.id); } }); app.get("/tmux_monitor/:hostId/search", async (req, res) => { const host = await requireHost(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(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(res, err, "search", host.id); } }); app.get("/tmux_monitor/:hostId/metrics", async (req, res) => { const host = await requireHost(req, res); if (!host) return; try { const metrics = await withHostConnection(host, async (conn) => { const panes = await listPanesRaw(conn); if (panes.length === 0) return []; return collectPaneMetrics(conn, panes); }); res.json({ panes: metrics }); } catch (err) { sendTmuxError(res, err, "metrics", host.id); } }); app.put("/tmux_monitor/:hostId/tags", async (req, res) => { const userId = (req as unknown as AuthenticatedRequest).userId; const host = await requireHost(req, res); if (!host) return; const { sessionName, tags } = req.body as { sessionName?: string; tags?: string[]; }; if (!sessionName || typeof sessionName !== "string") { return res.status(400).json({ error: "Missing session name" }); } if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) { return res.status(400).json({ error: "Tags must be an array of strings" }); } const cleanTags = [ ...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)), ].slice(0, 20); try { const db = getDb(); await db .delete(tmuxSessionTags) .where( and( eq(tmuxSessionTags.userId, userId), eq(tmuxSessionTags.hostId, host.id), eq(tmuxSessionTags.sessionName, sessionName), ), ); if (cleanTags.length > 0) { await db.insert(tmuxSessionTags).values( cleanTags.map((tag) => ({ userId, hostId: host.id, sessionName, tag, })), ); } await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); res.json({ sessionName, tags: cleanTags }); } catch (err) { sshLogger.error( `Failed to save tmux session tags for host ${host.id}`, err, ); res.status(500).json({ error: toErrorMessage(err) }); } }); const PORT = 30010; app.listen(PORT, () => {});